diff --git a/contracts/telemetry/telemetry.go b/contracts/telemetry/telemetry.go index 9fa4e175e..903ae5ba4 100644 --- a/contracts/telemetry/telemetry.go +++ b/contracts/telemetry/telemetry.go @@ -9,6 +9,8 @@ import ( oteltrace "go.opentelemetry.io/otel/trace" ) +type Resolver = func() Telemetry + type Telemetry interface { // Logger returns a log.Logger instance for emitting structured log records under the given instrumentation name. // Optional log.LoggerOption parameters allow customization of logger behavior. diff --git a/go.mod b/go.mod index 243f29713..6395a9954 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v3 v3.6.2 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 go.opentelemetry.io/contrib/propagators/b3 v1.39.0 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 @@ -62,6 +63,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chigopher/pathlib v0.19.1 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect diff --git a/go.sum b/go.sum index 36e95e2a5..39ea6457c 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -274,6 +276,8 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk= go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= diff --git a/http/client/config.go b/http/client/config.go index d35727aa0..6a2d05ad0 100644 --- a/http/client/config.go +++ b/http/client/config.go @@ -33,4 +33,7 @@ type Config struct { // IdleConnTimeout is the maximum amount of time an idle (keep-alive) connection // will remain idle before closing itself. IdleConnTimeout time.Duration `json:"idle_conn_timeout"` + + // EnableTelemetry determines if OpenTelemetry tracing/metrics are enabled for this client. + EnableTelemetry bool `json:"enable_telemetry"` } diff --git a/http/client/factory.go b/http/client/factory.go index a1f649d6f..55ff9d03f 100644 --- a/http/client/factory.go +++ b/http/client/factory.go @@ -4,9 +4,12 @@ import ( "net/http" "sync" + contractsconfig "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/http/client" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" "github.com/goravel/framework/errors" + telemetryhttp "github.com/goravel/framework/telemetry/instrumentation/http" ) var _ client.Factory = (*Factory)(nil) @@ -14,8 +17,11 @@ var _ client.Factory = (*Factory)(nil) type Factory struct { client.Request - json foundation.Json - config *FactoryConfig + factoryConfig *FactoryConfig + config contractsconfig.Config + json foundation.Json + telemetryResolver contractstelemetry.Resolver + clients sync.Map mu sync.RWMutex @@ -24,14 +30,21 @@ type Factory struct { stray []string } -func NewFactory(config *FactoryConfig, json foundation.Json) (*Factory, error) { - if config == nil { +func NewFactory( + factoryConfig *FactoryConfig, + config contractsconfig.Config, + json foundation.Json, + telemetryResolver contractstelemetry.Resolver, +) (*Factory, error) { + if factoryConfig == nil { return nil, errors.HttpClientConfigNotSet } factory := &Factory{ - config: config, - json: json, + factoryConfig: factoryConfig, + config: config, + json: json, + telemetryResolver: telemetryResolver, } if err := factory.bindDefault(); err != nil { @@ -81,7 +94,7 @@ func (r *Factory) AssertSentCount(count int) bool { } func (r *Factory) Client(names ...string) client.Request { - name := r.config.Default + name := r.factoryConfig.Default if len(names) > 0 && names[0] != "" { name = names[0] } @@ -95,7 +108,7 @@ func (r *Factory) Client(names ...string) client.Request { return newRequestWithError(err) } - cfg := r.config.Clients[name] + cfg := r.factoryConfig.Clients[name] return NewRequest(httpClient, r.json, cfg.BaseUrl, name) } @@ -151,7 +164,7 @@ func (r *Factory) Sequence() client.FakeSequence { } func (r *Factory) bindDefault() error { - name := r.config.Default + name := r.factoryConfig.Default c, err := r.resolveClient(name, r.fakeState) if err != nil { return err @@ -159,7 +172,7 @@ func (r *Factory) bindDefault() error { // Bind the default client to the embedded Request implementation // so that methods like Http.Get() use the default configuration. - r.Request = NewRequest(c, r.json, r.config.Clients[name].BaseUrl, name) + r.Request = NewRequest(c, r.json, r.factoryConfig.Clients[name].BaseUrl, name) return nil } @@ -184,7 +197,7 @@ func (r *Factory) resolveClient(name string, state *FakeState) (*http.Client, er return val.(*http.Client), nil } - cfg, ok := r.config.Clients[name] + cfg, ok := r.factoryConfig.Clients[name] if !ok { return nil, errors.HttpClientConnectionNotFound.Args(name) } @@ -198,6 +211,10 @@ func (r *Factory) resolveClient(name string, state *FakeState) (*http.Client, er baseTransport.IdleConnTimeout = cfg.IdleConnTimeout var transport http.RoundTripper = baseTransport + if cfg.EnableTelemetry && r.telemetryResolver != nil { + transport = telemetryhttp.NewTransport(r.config, r.telemetryResolver(), transport) + } + if state != nil { // If testing mode is active, wrap the real transport with our interceptor. transport = NewFakeTransport(state, baseTransport, r.json) diff --git a/http/client/factory_test.go b/http/client/factory_test.go index 4737d8013..55cdcdb52 100644 --- a/http/client/factory_test.go +++ b/http/client/factory_test.go @@ -10,18 +10,28 @@ import ( "time" "github.com/stretchr/testify/suite" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + tracenoop "go.opentelemetry.io/otel/trace/noop" "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/http/client" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" "github.com/goravel/framework/errors" "github.com/goravel/framework/foundation/json" + mocksconfig "github.com/goravel/framework/mocks/config" + mockstelemetry "github.com/goravel/framework/mocks/telemetry" ) type FactoryTestSuite struct { suite.Suite - json foundation.Json - factory *Factory - config *FactoryConfig + json foundation.Json + factory *Factory + factoryConfig *FactoryConfig + mockConfig *mocksconfig.Config + mockTelemetry *mockstelemetry.Telemetry + mockResolver contractstelemetry.Resolver } func TestFactoryTestSuite(t *testing.T) { @@ -30,7 +40,14 @@ func TestFactoryTestSuite(t *testing.T) { func (s *FactoryTestSuite) SetupTest() { s.json = json.New() - s.config = &FactoryConfig{ + s.mockConfig = mocksconfig.NewConfig(s.T()) + s.mockTelemetry = mockstelemetry.NewTelemetry(s.T()) + + s.mockResolver = func() contractstelemetry.Telemetry { + return s.mockTelemetry + } + + s.factoryConfig = &FactoryConfig{ Default: "main", Clients: map[string]Config{ "main": { @@ -48,7 +65,7 @@ func (s *FactoryTestSuite) SetupTest() { }, } var err error - s.factory, err = NewFactory(s.config, s.json) + s.factory, err = NewFactory(s.factoryConfig, s.mockConfig, s.json, s.mockResolver) s.NoError(err) } @@ -77,8 +94,8 @@ func (s *FactoryTestSuite) TestClient_Resolution() { } func (s *FactoryTestSuite) TestErrorHandling() { - s.Run("handles nil config safely", func() { - f, err := NewFactory(nil, s.json) + s.Run("handles nil factoryConfig safely", func() { + f, err := NewFactory(nil, s.mockConfig, s.json, s.mockResolver) s.Nil(f) s.ErrorIs(err, errors.HttpClientConfigNotSet) }) @@ -93,12 +110,12 @@ func (s *FactoryTestSuite) TestErrorHandling() { s.Contains(err.Error(), "[missing_client]") }) - s.Run("returns lazy error when default is empty in config", func() { + s.Run("returns lazy error when default is empty in factoryConfig", func() { cfg := &FactoryConfig{ Default: "", Clients: map[string]Config{"main": {}}, } - f, err := NewFactory(cfg, s.json) + f, err := NewFactory(cfg, s.mockConfig, s.json, s.mockResolver) s.ErrorIs(err, errors.HttpClientDefaultNotSet) s.Nil(f) }) @@ -113,7 +130,7 @@ func (s *FactoryTestSuite) TestConcurrency() { "new2": {BaseUrl: "https://new2.com", Timeout: 3 * time.Second}, }, } - f, err := NewFactory(cfg, s.json) + f, err := NewFactory(cfg, s.mockConfig, s.json, s.mockResolver) s.NoError(err) var wg sync.WaitGroup @@ -163,7 +180,7 @@ func (s *FactoryTestSuite) TestBaseUrl_Override() { "main": {BaseUrl: "https://wrong-url.com"}, }, } - f, err := NewFactory(cfg, s.json) + f, err := NewFactory(cfg, s.mockConfig, s.json, s.mockResolver) s.NoError(err) s.Run("overrides config base url", func() { @@ -182,7 +199,12 @@ func (s *FactoryTestSuite) TestProxy_HttpMethods() { })) defer server.Close() - f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.json) + f, err := NewFactory( + &FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, + s.mockConfig, + s.json, + s.mockResolver, + ) s.NoError(err) tests := []struct { @@ -229,7 +251,12 @@ func (s *FactoryTestSuite) TestProxy_Headers() { })) defer server.Close() - f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.json) + f, err := NewFactory( + &FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, + s.mockConfig, + s.json, + s.mockResolver, + ) s.NoError(err) s.Run("WithHeader & WithHeaders", func() { @@ -306,7 +333,7 @@ func (s *FactoryTestSuite) TestProxy_QueryParameters() { })) defer server.Close() - f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.json) + f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.mockConfig, s.json, s.mockResolver) s.NoError(err) s.Run("WithQueryParameter", func() { @@ -342,7 +369,7 @@ func (s *FactoryTestSuite) TestProxy_UrlParameters() { })) defer server.Close() - f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.json) + f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.mockConfig, s.json, s.mockResolver) s.NoError(err) s.Run("WithUrlParameter", func() { @@ -371,7 +398,7 @@ func (s *FactoryTestSuite) TestProxy_Cookies() { })) defer server.Close() - f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.json) + f, err := NewFactory(&FactoryConfig{Default: "test", Clients: map[string]Config{"test": {BaseUrl: server.URL}}}, s.mockConfig, s.json, s.mockResolver) s.NoError(err) s.Run("WithCookie", func() { @@ -423,7 +450,7 @@ func (s *FactoryTestSuite) TestRouting_Integration() { "server_b": {BaseUrl: serverB.URL}, }, } - f, err := NewFactory(cfg, s.json) + f, err := NewFactory(cfg, s.mockConfig, s.json, s.mockResolver) s.NoError(err) s.Run("proxy methods hit default server", func() { @@ -443,6 +470,41 @@ func (s *FactoryTestSuite) TestRouting_Integration() { }) } +func (s *FactoryTestSuite) TestTelemetry_Integration() { + factoryConfig := &FactoryConfig{ + Default: "telemetry_client", + Clients: map[string]Config{ + "telemetry_client": { + BaseUrl: "https://example.com", + EnableTelemetry: true, + }, + }, + } + + s.mockConfig.EXPECT().GetBool("telemetry.instrumentation.http_client", true). + Return(true).Once() + + s.mockTelemetry.EXPECT().TracerProvider().Return(tracenoop.NewTracerProvider()).Once() + s.mockTelemetry.EXPECT().MeterProvider().Return(metricnoop.NewMeterProvider()).Once() + s.mockTelemetry.EXPECT().Propagator().Return(propagation.NewCompositeTextMapPropagator()).Once() + + factory, err := NewFactory(factoryConfig, s.mockConfig, s.json, s.mockResolver) + s.NoError(err) + + req := factory.Client("telemetry_client") + s.NotNil(req) + + concreteReq, ok := req.(*Request) + s.True(ok, "Request should be of type *client.Request") + + httpClient := concreteReq.HttpClient() + s.NotNil(httpClient.Transport) + s.IsType(&otelhttp.Transport{}, httpClient.Transport) + + _, isPlainTransport := httpClient.Transport.(*http.Transport) + s.False(isPlainTransport) +} + func (s *FactoryTestSuite) TestFake_GithubUserProfile() { userMap := map[string]any{ "login": "goravel", @@ -547,7 +609,7 @@ func (s *FactoryTestSuite) TestFactory_AllowStrayRequests() { })) defer server.Close() - s.factory.config.Clients["github"] = Config{BaseUrl: server.URL} + s.factory.factoryConfig.Clients["github"] = Config{BaseUrl: server.URL} s.factory.Reset() s.factory.Fake(nil).PreventStrayRequests().AllowStrayRequests([]string{server.URL + "/*"}) @@ -593,7 +655,7 @@ func (s *FactoryTestSuite) TestIntegration_RealServer() { "local": {BaseUrl: server.URL}, }, } - factory, err := NewFactory(cfg, s.json) + factory, err := NewFactory(cfg, s.mockConfig, s.json, s.mockResolver) s.NoError(err) resp, err := factory.Post("/repos", nil) diff --git a/http/service_provider.go b/http/service_provider.go index 1efae02b8..93baaae52 100644 --- a/http/service_provider.go +++ b/http/service_provider.go @@ -4,6 +4,7 @@ import ( contractsbinding "github.com/goravel/framework/contracts/binding" contractsconsole "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/foundation" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" "github.com/goravel/framework/errors" "github.com/goravel/framework/http/client" "github.com/goravel/framework/http/console" @@ -40,7 +41,7 @@ func (r *ServiceProvider) Register(app foundation.Application) { return nil, errors.ConfigFacadeNotSet.SetModule(errors.ModuleHttp) } - j := app.GetJson() + j := app.Json() if j == nil { return nil, errors.JSONParserNotSet.SetModule(errors.ModuleHttp) } @@ -50,7 +51,9 @@ func (r *ServiceProvider) Register(app foundation.Application) { return nil, err } - return client.NewFactory(factoryConfig, j) + return client.NewFactory(factoryConfig, configFacade, j, func() contractstelemetry.Telemetry { + return app.MakeTelemetry() + }) }) } diff --git a/log/application.go b/log/application.go index 02a9d2317..3ccfac066 100644 --- a/log/application.go +++ b/log/application.go @@ -11,6 +11,7 @@ import ( "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/contracts/http" "github.com/goravel/framework/contracts/log" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" "github.com/goravel/framework/errors" "github.com/goravel/framework/log/logger" telemetrylog "github.com/goravel/framework/telemetry/instrumentation/log" @@ -20,13 +21,14 @@ var channelToHandlers sync.Map type Application struct { log.Writer - ctx context.Context - channels []string - config config.Config - json foundation.Json + ctx context.Context + channels []string + config config.Config + json foundation.Json + telemetryResolver contractstelemetry.Resolver } -func NewApplication(ctx context.Context, channels []string, config config.Config, json foundation.Json) (*Application, error) { +func NewApplication(ctx context.Context, channels []string, config config.Config, json foundation.Json, resolver contractstelemetry.Resolver) (*Application, error) { var handlers []slog.Handler if len(channels) == 0 && config != nil { @@ -36,7 +38,7 @@ func NewApplication(ctx context.Context, channels []string, config config.Config } for _, channel := range channels { - channelHandlers, err := getHandlers(config, json, channel) + channelHandlers, err := getHandlers(config, json, resolver, channel) if err != nil { return nil, err } @@ -47,11 +49,12 @@ func NewApplication(ctx context.Context, channels []string, config config.Config slogLogger := slog.New(slogmulti.Fanout(handlers...)) return &Application{ - ctx: ctx, - channels: channels, - config: config, - json: json, - Writer: NewWriter(ctx, slogLogger), + ctx: ctx, + channels: channels, + config: config, + json: json, + telemetryResolver: resolver, + Writer: NewWriter(ctx, slogLogger), }, nil } @@ -60,7 +63,7 @@ func (r *Application) WithContext(ctx context.Context) log.Log { ctx = httpCtx.Context() } - app, err := NewApplication(ctx, r.channels, r.config, r.json) + app, err := NewApplication(ctx, r.channels, r.config, r.json, r.telemetryResolver) if err != nil { r.Error(err) @@ -75,7 +78,7 @@ func (r *Application) Channel(channel string) log.Log { return r } - app, err := NewApplication(r.ctx, []string{channel}, r.config, r.json) + app, err := NewApplication(r.ctx, []string{channel}, r.config, r.json, r.telemetryResolver) if err != nil { r.Error(err) @@ -90,7 +93,7 @@ func (r *Application) Stack(channels []string) log.Log { return r } - app, err := NewApplication(r.ctx, channels, r.config, r.json) + app, err := NewApplication(r.ctx, channels, r.config, r.json, r.telemetryResolver) if err != nil { r.Error(err) @@ -101,7 +104,7 @@ func (r *Application) Stack(channels []string) log.Log { } // getHandlers returns slog log handlers for the specified channel. -func getHandlers(config config.Config, json foundation.Json, channel string) ([]slog.Handler, error) { +func getHandlers(config config.Config, json foundation.Json, telemetryResolver contractstelemetry.Resolver, channel string) ([]slog.Handler, error) { var handlers []slog.Handler handlersAny, ok := channelToHandlers.Load(channel) if ok { @@ -123,7 +126,7 @@ func getHandlers(config config.Config, json foundation.Json, channel string) ([] return nil, errors.LogDriverCircularReference.Args("stack") } - channelHandlers, err := getHandlers(config, json, stackChannel) + channelHandlers, err := getHandlers(config, json, telemetryResolver, stackChannel) if err != nil { return nil, err } @@ -156,7 +159,7 @@ func getHandlers(config config.Config, json foundation.Json, channel string) ([] handlers = append(handlers, HandlerToSlogHandler(logger.NewConsoleHandler(config, json, level, formatter))) } case log.DriverOtel: - logLogger := telemetrylog.NewTelemetryChannel() + logLogger := telemetrylog.NewLazyTelemetryChannel(config, telemetryResolver) handler, err := logLogger.Handle(channelPath) if err != nil { return nil, err diff --git a/log/application_test.go b/log/application_test.go index 3d05b0d73..da520ecf4 100644 --- a/log/application_test.go +++ b/log/application_test.go @@ -6,10 +6,13 @@ import ( "github.com/stretchr/testify/assert" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" "github.com/goravel/framework/errors" "github.com/goravel/framework/foundation/json" mocksconfig "github.com/goravel/framework/mocks/config" + mockstelemetry "github.com/goravel/framework/mocks/telemetry" "github.com/goravel/framework/support/file" + telemetrylog "github.com/goravel/framework/telemetry/instrumentation/log" ) // clearChannelCache clears all entries from the channel cache @@ -22,7 +25,11 @@ func clearChannelCache() { func TestNewApplication(t *testing.T) { j := json.New() - app, err := NewApplication(context.Background(), nil, nil, j) + telemetryResolver := func() contractstelemetry.Telemetry { + return mockstelemetry.NewTelemetry(t) + } + + app, err := NewApplication(context.Background(), nil, nil, j, telemetryResolver) assert.Nil(t, err) assert.NotNil(t, app) @@ -33,7 +40,7 @@ func TestNewApplication(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test.level").Return("debug").Times(2) // Called for file handler and console handler when print=true mockConfig.EXPECT().GetString("logging.channels.test.formatter", "text").Return("text").Times(2) // Called for file handler and console handler when print=true mockConfig.EXPECT().GetBool("logging.channels.test.print").Return(true).Once() - app, err = NewApplication(context.Background(), nil, mockConfig, j) + app, err = NewApplication(context.Background(), nil, mockConfig, j, telemetryResolver) assert.Nil(t, err) assert.NotNil(t, app) @@ -44,7 +51,7 @@ func TestNewApplication(t *testing.T) { mockConfig.EXPECT().GetString("logging.default").Return("test").Once() mockConfig.EXPECT().GetString("logging.channels.test.driver").Return("test").Once() - app, err = NewApplication(context.Background(), nil, mockConfig, j) + app, err = NewApplication(context.Background(), nil, mockConfig, j, telemetryResolver) assert.EqualError(t, err, errors.LogDriverNotSupported.Args("test").Error()) assert.Nil(t, app) @@ -65,7 +72,7 @@ func TestApplication_Channel(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test.formatter", "text").Return("text").Times(2) // Called for file handler and console handler mockConfig.EXPECT().GetBool("logging.channels.test.print").Return(true).Once() - app, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + app, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, app) assert.NotNil(t, app.Channel("")) @@ -101,7 +108,7 @@ func TestApplication_Stack(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test.level").Return("debug").Times(2) // Called for file handler and console handler mockConfig.EXPECT().GetString("logging.channels.test.formatter", "text").Return("text").Times(2) // Called for file handler and console handler mockConfig.EXPECT().GetBool("logging.channels.test.print").Return(true).Once() - app, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + app, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, app) @@ -137,7 +144,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test-single.formatter", "text").Return("text").Once() mockConfig.EXPECT().GetBool("logging.channels.test-single.print").Return(false).Once() - handlers, err := getHandlers(mockConfig, j, "test-single") + handlers, err := getHandlers(mockConfig, j, nil, "test-single") assert.NoError(t, err) assert.Len(t, handlers, 1) @@ -154,7 +161,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test-single-print.formatter", "text").Return("text").Times(2) mockConfig.EXPECT().GetBool("logging.channels.test-single-print.print").Return(true).Once() - handlers, err := getHandlers(mockConfig, j, "test-single-print") + handlers, err := getHandlers(mockConfig, j, nil, "test-single-print") assert.NoError(t, err) assert.Len(t, handlers, 2) // file handler + console handler @@ -172,7 +179,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetBool("logging.channels.test-daily.print").Return(false).Once() mockConfig.EXPECT().GetInt("logging.channels.test-daily.days").Return(7).Once() - handlers, err := getHandlers(mockConfig, j, "test-daily") + handlers, err := getHandlers(mockConfig, j, nil, "test-daily") assert.NoError(t, err) assert.Len(t, handlers, 1) @@ -189,7 +196,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetBool("logging.channels.test-daily-print.print").Return(true).Once() mockConfig.EXPECT().GetInt("logging.channels.test-daily-print.days").Return(3).Once() - handlers, err := getHandlers(mockConfig, j, "test-daily-print") + handlers, err := getHandlers(mockConfig, j, nil, "test-daily-print") assert.NoError(t, err) assert.Len(t, handlers, 2) @@ -197,37 +204,46 @@ func TestGetHandlers(t *testing.T) { clearChannelCache() }) - t.Run("otel driver", func(t *testing.T) { + t.Run("OTeL driver", func(t *testing.T) { mockConfig := mocksconfig.NewConfig(t) + mockConfig.EXPECT().GetBool("telemetry.instrumentation.log", true).Return(true).Once() mockConfig.EXPECT().GetString("logging.channels.test-otel.driver").Return("otel").Once() + mockConfig.EXPECT().GetString("logging.channels.test-otel.instrument_name", telemetrylog.DefaultInstrumentationName). + Return("goravel").Once() + mockConfig.EXPECT().GetBool("logging.channels.test-otel.print").Return(false).Once() - handlers, err := getHandlers(mockConfig, j, "test-otel") - // In unit tests, telemetry facade is not initialized - // so we expect an error or empty handlers depending on implementation - if err != nil { - assert.Error(t, err) - } else { - assert.Len(t, handlers, 0) + mockTelemetry := mockstelemetry.NewTelemetry(t) + resolver := func() contractstelemetry.Telemetry { + return mockTelemetry } - // Cleanup + handlers, err := getHandlers(mockConfig, j, resolver, "test-otel") + assert.NoError(t, err) + assert.Len(t, handlers, 1) + clearChannelCache() }) - t.Run("otel driver with print enabled", func(t *testing.T) { + t.Run("OTeL driver with print enabled", func(t *testing.T) { mockConfig := mocksconfig.NewConfig(t) + mockConfig.EXPECT().GetBool("telemetry.instrumentation.log", true).Return(true).Once() mockConfig.EXPECT().GetString("logging.channels.test-otel-print.driver").Return("otel").Once() - - handlers, err := getHandlers(mockConfig, j, "test-otel-print") - // In unit tests, telemetry facade is not initialized - // so we expect an error or empty handlers depending on implementation - if err != nil { - assert.Error(t, err) - } else { - assert.Len(t, handlers, 0) + mockConfig.EXPECT().GetString("logging.channels.test-otel-print.instrument_name", telemetrylog.DefaultInstrumentationName). + Return("goravel").Once() + mockConfig.EXPECT().GetString("logging.channels.test-otel-print.level").Return("debug").Once() + mockConfig.EXPECT().GetBool("logging.channels.test-otel-print.print").Return(true).Once() + mockConfig.EXPECT().GetString("logging.channels.test-otel-print.formatter", "text"). + Return("text").Once() + + mockTelemetry := mockstelemetry.NewTelemetry(t) + resolver := func() contractstelemetry.Telemetry { + return mockTelemetry } - // Cleanup + handlers, err := getHandlers(mockConfig, j, resolver, "test-otel-print") + assert.NoError(t, err) + assert.Len(t, handlers, 2) + clearChannelCache() }) @@ -250,7 +266,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.stack-single2.formatter", "text").Return("text").Once() mockConfig.EXPECT().GetBool("logging.channels.stack-single2.print").Return(false).Once() - handlers, err := getHandlers(mockConfig, j, "test-stack") + handlers, err := getHandlers(mockConfig, j, nil, "test-stack") assert.NoError(t, err) assert.Len(t, handlers, 2) // One from each channel @@ -265,7 +281,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test-circular.driver").Return("stack").Once() mockConfig.EXPECT().Get("logging.channels.test-circular.channels").Return([]string{"test-circular"}).Once() - handlers, err := getHandlers(mockConfig, j, "test-circular") + handlers, err := getHandlers(mockConfig, j, nil, "test-circular") assert.Error(t, err) assert.EqualError(t, err, errors.LogDriverCircularReference.Args("stack").Error()) assert.Nil(t, handlers) @@ -279,7 +295,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test-badstack.driver").Return("stack").Once() mockConfig.EXPECT().Get("logging.channels.test-badstack.channels").Return("not-a-slice").Once() - handlers, err := getHandlers(mockConfig, j, "test-badstack") + handlers, err := getHandlers(mockConfig, j, nil, "test-badstack") assert.Error(t, err) assert.EqualError(t, err, errors.LogChannelNotFound.Args("test-badstack").Error()) assert.Nil(t, handlers) @@ -295,7 +311,7 @@ func TestGetHandlers(t *testing.T) { customLogger := &CustomLogger{} mockConfig.EXPECT().Get("logging.channels.test-custom.via").Return(customLogger).Once() - handlers, err := getHandlers(mockConfig, j, "test-custom") + handlers, err := getHandlers(mockConfig, j, nil, "test-custom") assert.NoError(t, err) assert.Len(t, handlers, 1) @@ -308,7 +324,7 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.test-badcustom.driver").Return("custom").Once() mockConfig.EXPECT().Get("logging.channels.test-badcustom.via").Return(nil).Once() - handlers, err := getHandlers(mockConfig, j, "test-badcustom") + handlers, err := getHandlers(mockConfig, j, nil, "test-badcustom") assert.Error(t, err) assert.EqualError(t, err, errors.LogChannelUnimplemented.Args("test-badcustom").Error()) assert.Nil(t, handlers) @@ -321,7 +337,7 @@ func TestGetHandlers(t *testing.T) { mockConfig := mocksconfig.NewConfig(t) mockConfig.EXPECT().GetString("logging.channels.test-unknown.driver").Return("unknown").Once() - handlers, err := getHandlers(mockConfig, j, "test-unknown") + handlers, err := getHandlers(mockConfig, j, nil, "test-unknown") assert.Error(t, err) assert.EqualError(t, err, errors.LogDriverNotSupported.Args("test-unknown").Error()) assert.Nil(t, handlers) @@ -339,12 +355,12 @@ func TestGetHandlers(t *testing.T) { mockConfig.EXPECT().GetBool("logging.channels.test-cached.print").Return(false).Once() // First call should use mock config - handlers1, err := getHandlers(mockConfig, j, "test-cached") + handlers1, err := getHandlers(mockConfig, j, nil, "test-cached") assert.NoError(t, err) assert.Len(t, handlers1, 1) // Second call should use cached handlers (no mock expectations needed) - handlers2, err := getHandlers(mockConfig, j, "test-cached") + handlers2, err := getHandlers(mockConfig, j, nil, "test-cached") assert.NoError(t, err) assert.Len(t, handlers2, 1) assert.Equal(t, handlers1, handlers2) diff --git a/log/service_provider.go b/log/service_provider.go index 2284e4ce4..086144b69 100644 --- a/log/service_provider.go +++ b/log/service_provider.go @@ -5,6 +5,7 @@ import ( "github.com/goravel/framework/contracts/binding" "github.com/goravel/framework/contracts/foundation" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" "github.com/goravel/framework/errors" ) @@ -28,11 +29,13 @@ func (r *ServiceProvider) Register(app foundation.Application) { return nil, errors.ConfigFacadeNotSet.SetModule(errors.ModuleLog) } - json := app.GetJson() + json := app.Json() if json == nil { return nil, errors.JSONParserNotSet.SetModule(errors.ModuleLog) } - return NewApplication(context.Background(), nil, config, json) + return NewApplication(context.Background(), nil, config, json, func() contractstelemetry.Telemetry { + return app.MakeTelemetry() + }) }) } diff --git a/log/writer_test.go b/log/writer_test.go index a6a1db50a..24b77908c 100644 --- a/log/writer_test.go +++ b/log/writer_test.go @@ -54,7 +54,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Debug("Debug Goravel") }, assert: func() { @@ -67,7 +67,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Debugf("Goravel: %s", "World") }, assert: func() { @@ -80,7 +80,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Info("Goravel") }, assert: func() { @@ -93,7 +93,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Infof("Goravel: %s", "World") }, assert: func() { @@ -106,7 +106,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Warning("Goravel") }, assert: func() { @@ -119,7 +119,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Warningf("Goravel: %s", "World") }, assert: func() { @@ -132,7 +132,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Error("Goravel") }, assert: func() { @@ -145,7 +145,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Errorf("Goravel: %s", "World") }, assert: func() { @@ -158,7 +158,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) }, assert: func() { assert.Panics(t, func() { @@ -173,7 +173,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) }, assert: func() { assert.Panics(t, func() { @@ -188,7 +188,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Code("code").Info("Goravel") }, assert: func() { @@ -201,7 +201,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Hint("hint").Info("Goravel") }, assert: func() { @@ -214,7 +214,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.In("domain").Info("Goravel") }, assert: func() { @@ -227,7 +227,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Owner("team@goravel.dev").Info("Goravel") }, assert: func() { @@ -240,7 +240,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Request(&TestRequest{}).Info("Goravel") }, assert: func() { @@ -265,7 +265,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Response(&TestResponse{}).Info("Goravel") }, assert: func() { @@ -289,7 +289,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Tags("tag").Info("Goravel") }, assert: func() { @@ -302,7 +302,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.User(map[string]any{"name": "kkumar-gcc"}).Info("Goravel") }, assert: func() { @@ -315,7 +315,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.With(map[string]any{"key": "value"}).Info("Goravel") }, assert: func() { @@ -328,7 +328,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.WithTrace().Info("Goravel") }, assert: func() { @@ -341,7 +341,7 @@ func TestWriter(t *testing.T) { setup: func() { mockConfig.EXPECT().GetString("app.env").Return("test").Times(4) - log, err = NewApplication(context.Background(), nil, mockConfig, j) + log, err = NewApplication(context.Background(), nil, mockConfig, j, nil) log.Error("test error") log.Info("test info") }, @@ -392,7 +392,7 @@ func TestWriter_WithContext(t *testing.T) { // app.env is called twice per log write (once for each handler: single + daily) mockConfig.EXPECT().GetString("app.env").Return("test").Twice() - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) ctx := context.Background() @@ -426,7 +426,7 @@ func TestWriter_LevelNotMatch(t *testing.T) { mockConfig.EXPECT().GetString("logging.channels.single.level").Return("info").Once() mockConfig.EXPECT().GetString("logging.channels.single.formatter", "text").Return("text").Once() - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) log.Debug("No Debug Goravel") @@ -444,7 +444,7 @@ func TestWriter_DailyLogWithDifferentDays(t *testing.T) { // We log twice in this test, so 2 * 2 = 4 calls mockConfig.EXPECT().GetString("app.env").Return("test").Times(4) - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, log) @@ -482,7 +482,7 @@ func TestWriterWithCustomLogger(t *testing.T) { filename := "custom.log" - logger, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + logger, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, logger) @@ -507,7 +507,7 @@ func TestWriter_Fatal(t *testing.T) { clearChannelCache() mockConfig := initMockConfig(t) - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, log) @@ -532,7 +532,7 @@ func TestWriter_Fatalf(t *testing.T) { clearChannelCache() mockConfig := initMockConfig(t) - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, log) @@ -574,7 +574,7 @@ func TestWriter_ConcurrentAccess(t *testing.T) { // Mock app.env for all log entries (goroutines * iterations) mockConfig.EXPECT().GetString("app.env").Return("test").Times(goroutines * iterations) - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, log) @@ -666,7 +666,7 @@ func TestWriter_NoEntryContamination(t *testing.T) { mockConfig := initMockConfig(t) mockConfig.EXPECT().GetString("app.env").Return("test").Times(2) - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, log) @@ -689,7 +689,7 @@ func TestWriter_FluentChainIsolation(t *testing.T) { mockConfig := initMockConfig(t) mockConfig.EXPECT().GetString("app.env").Return("test").Times(4) - log, err := NewApplication(context.Background(), nil, mockConfig, json.New()) + log, err := NewApplication(context.Background(), nil, mockConfig, json.New(), nil) assert.Nil(t, err) assert.NotNil(t, log) diff --git a/telemetry/instrumentation/grpc/handler.go b/telemetry/instrumentation/grpc/handler.go index bbd12f676..f7667a849 100644 --- a/telemetry/instrumentation/grpc/handler.go +++ b/telemetry/instrumentation/grpc/handler.go @@ -4,39 +4,45 @@ import ( "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc/stats" - "github.com/goravel/framework/support/color" - "github.com/goravel/framework/telemetry" + contractsconfig "github.com/goravel/framework/contracts/config" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" ) // NewServerStatsHandler creates an OTel stats handler for the server. -func NewServerStatsHandler(opts ...Option) stats.Handler { - if telemetry.TelemetryFacade == nil { - color.Warningln("[Telemetry] Facade not initialized. gRPC server stats instrumentation is disabled.") +func NewServerStatsHandler(config contractsconfig.Config, telemetry contractstelemetry.Telemetry, opts ...Option) stats.Handler { + if config == nil || !config.GetBool("telemetry.instrumentation.grpc_server") { return nil } - finalOpts := append(getCommonOptions(), opts...) + if telemetry == nil { + return nil + } + + finalOpts := append(getCommonOptions(telemetry), opts...) return otelgrpc.NewServerHandler(finalOpts...) } // NewClientStatsHandler creates an OTel stats handler for the client. -func NewClientStatsHandler(opts ...Option) stats.Handler { - if telemetry.TelemetryFacade == nil { - color.Warningln("[Telemetry] Facade not initialized. gRPC client stats instrumentation is disabled.") +func NewClientStatsHandler(config contractsconfig.Config, telemetry contractstelemetry.Telemetry, opts ...Option) stats.Handler { + if config == nil || !config.GetBool("telemetry.instrumentation.grpc_client") { + return nil + } + + if telemetry == nil { return nil } - finalOpts := append(getCommonOptions(), opts...) + finalOpts := append(getCommonOptions(telemetry), opts...) return otelgrpc.NewClientHandler(finalOpts...) } -func getCommonOptions() []otelgrpc.Option { +func getCommonOptions(telemetry contractstelemetry.Telemetry) []otelgrpc.Option { return []otelgrpc.Option{ - otelgrpc.WithTracerProvider(telemetry.TelemetryFacade.TracerProvider()), - otelgrpc.WithMeterProvider(telemetry.TelemetryFacade.MeterProvider()), - otelgrpc.WithPropagators(telemetry.TelemetryFacade.Propagator()), + otelgrpc.WithTracerProvider(telemetry.TracerProvider()), + otelgrpc.WithMeterProvider(telemetry.MeterProvider()), + otelgrpc.WithPropagators(telemetry.Propagator()), otelgrpc.WithMessageEvents(otelgrpc.ReceivedEvents, otelgrpc.SentEvents), } } diff --git a/telemetry/instrumentation/grpc/handler_test.go b/telemetry/instrumentation/grpc/handler_test.go index c75754c4d..d5db1f961 100644 --- a/telemetry/instrumentation/grpc/handler_test.go +++ b/telemetry/instrumentation/grpc/handler_test.go @@ -1,7 +1,6 @@ package grpc import ( - "io" "testing" "github.com/stretchr/testify/suite" @@ -10,23 +9,13 @@ import ( tracenoop "go.opentelemetry.io/otel/trace/noop" "google.golang.org/grpc/stats" - contractstelemetry "github.com/goravel/framework/contracts/telemetry" + mocksconfig "github.com/goravel/framework/mocks/config" mockstelemetry "github.com/goravel/framework/mocks/telemetry" - "github.com/goravel/framework/support/color" "github.com/goravel/framework/telemetry" ) type HandlerTestSuite struct { suite.Suite - originalFacade contractstelemetry.Telemetry -} - -func (s *HandlerTestSuite) SetupTest() { - s.originalFacade = telemetry.TelemetryFacade -} - -func (s *HandlerTestSuite) TearDownTest() { - telemetry.TelemetryFacade = s.originalFacade } func TestHandlerTestSuite(t *testing.T) { @@ -36,48 +25,49 @@ func TestHandlerTestSuite(t *testing.T) { func (s *HandlerTestSuite) TestServerStatsHandler() { tests := []struct { name string - setup func(*mockstelemetry.Telemetry) - assert func() + setup func(*mockstelemetry.Telemetry, *mocksconfig.Config) + assert func(*mockstelemetry.Telemetry, *mocksconfig.Config) }{ { - name: "returns nil and logs warning when telemetry facade is nil", - setup: func(_ *mockstelemetry.Telemetry) { - telemetry.TelemetryFacade = nil + name: "Returns nil if config is disabled", + setup: func(_ *mockstelemetry.Telemetry, mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().GetBool("telemetry.instrumentation.grpc_server").Return(false).Once() }, - assert: func() { - var handler stats.Handler - out := color.CaptureOutput(func(w io.Writer) { - handler = NewServerStatsHandler() - }) - - s.Nil(handler) - s.Contains(out, "[Telemetry] Facade not initialized. gRPC server stats instrumentation is disabled.") + assert: func(t *mockstelemetry.Telemetry, c *mocksconfig.Config) { + s.Nil(NewServerStatsHandler(c, t)) }, }, { - name: "returns handler when telemetry facade is set", - setup: func(mockTelemetry *mockstelemetry.Telemetry) { + name: "Returns nil (no warning) if telemetry is nil", + setup: func(_ *mockstelemetry.Telemetry, mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().GetBool("telemetry.instrumentation.grpc_server").Return(true).Once() + }, + assert: func(_ *mockstelemetry.Telemetry, c *mocksconfig.Config) { + s.Nil(NewServerStatsHandler(c, nil)) + }, + }, + { + name: "Returns handler when enabled and dependencies set", + setup: func(mockTelemetry *mockstelemetry.Telemetry, mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().GetBool("telemetry.instrumentation.grpc_server").Return(true).Once() mockTelemetry.EXPECT().TracerProvider().Return(tracenoop.NewTracerProvider()).Once() mockTelemetry.EXPECT().MeterProvider().Return(metricnoop.NewMeterProvider()).Once() mockTelemetry.EXPECT().Propagator().Return(propagation.NewCompositeTextMapPropagator()).Once() - - telemetry.TelemetryFacade = mockTelemetry }, - assert: func() { - s.NotNil(NewServerStatsHandler()) + assert: func(t *mockstelemetry.Telemetry, c *mocksconfig.Config) { + s.NotNil(NewServerStatsHandler(c, t)) }, }, { - name: "accepts options", - setup: func(mockTelemetry *mockstelemetry.Telemetry) { + name: "Accepts options", + setup: func(mockTelemetry *mockstelemetry.Telemetry, mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().GetBool("telemetry.instrumentation.grpc_server").Return(true).Once() mockTelemetry.EXPECT().TracerProvider().Return(tracenoop.NewTracerProvider()).Once() mockTelemetry.EXPECT().MeterProvider().Return(metricnoop.NewMeterProvider()).Once() mockTelemetry.EXPECT().Propagator().Return(propagation.NewCompositeTextMapPropagator()).Once() - - telemetry.TelemetryFacade = mockTelemetry }, - assert: func() { - handler := NewServerStatsHandler( + assert: func(t *mockstelemetry.Telemetry, c *mocksconfig.Config) { + handler := NewServerStatsHandler(c, t, WithFilter(func(info *stats.RPCTagInfo) bool { return true }), WithMessageEvents(ReceivedEvents, SentEvents), WithMetricAttributes(telemetry.String("key", "value")), @@ -90,9 +80,12 @@ func (s *HandlerTestSuite) TestServerStatsHandler() { for _, test := range tests { s.Run(test.name, func() { mockTelemetry := mockstelemetry.NewTelemetry(s.T()) + mockConfig := mocksconfig.NewConfig(s.T()) - test.setup(mockTelemetry) - test.assert() + if test.setup != nil { + test.setup(mockTelemetry, mockConfig) + } + test.assert(mockTelemetry, mockConfig) }) } } @@ -100,52 +93,37 @@ func (s *HandlerTestSuite) TestServerStatsHandler() { func (s *HandlerTestSuite) TestClientStatsHandler() { tests := []struct { name string - setup func(*mockstelemetry.Telemetry) - assert func() + setup func(*mockstelemetry.Telemetry, *mocksconfig.Config) + assert func(*mockstelemetry.Telemetry, *mocksconfig.Config) }{ { - name: "returns nil and logs warning when telemetry facade is nil", - setup: func(_ *mockstelemetry.Telemetry) { - telemetry.TelemetryFacade = nil + name: "Returns nil if config is disabled", + setup: func(_ *mockstelemetry.Telemetry, mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().GetBool("telemetry.instrumentation.grpc_client").Return(false).Once() }, - assert: func() { - var handler stats.Handler - out := color.CaptureOutput(func(w io.Writer) { - handler = NewClientStatsHandler() - }) - - s.Nil(handler) - s.Contains(out, "[Telemetry] Facade not initialized. gRPC client stats instrumentation is disabled.") + assert: func(t *mockstelemetry.Telemetry, c *mocksconfig.Config) { + s.Nil(NewClientStatsHandler(c, t)) }, }, { - name: "returns handler when telemetry facade is set", - setup: func(mockTelemetry *mockstelemetry.Telemetry) { - mockTelemetry.EXPECT().TracerProvider().Return(tracenoop.NewTracerProvider()).Once() - mockTelemetry.EXPECT().MeterProvider().Return(metricnoop.NewMeterProvider()).Once() - mockTelemetry.EXPECT().Propagator().Return(propagation.NewCompositeTextMapPropagator()).Once() - - telemetry.TelemetryFacade = mockTelemetry + name: "Returns nil (no warning) if telemetry is nil", + setup: func(_ *mockstelemetry.Telemetry, mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().GetBool("telemetry.instrumentation.grpc_client").Return(true).Once() }, - assert: func() { - s.NotNil(NewClientStatsHandler()) + assert: func(_ *mockstelemetry.Telemetry, c *mocksconfig.Config) { + s.Nil(NewClientStatsHandler(c, nil)) }, }, { - name: "accepts options", - setup: func(mockTelemetry *mockstelemetry.Telemetry) { + name: "Returns handler when dependencies set", + setup: func(mockTelemetry *mockstelemetry.Telemetry, mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().GetBool("telemetry.instrumentation.grpc_client").Return(true).Once() mockTelemetry.EXPECT().TracerProvider().Return(tracenoop.NewTracerProvider()).Once() mockTelemetry.EXPECT().MeterProvider().Return(metricnoop.NewMeterProvider()).Once() mockTelemetry.EXPECT().Propagator().Return(propagation.NewCompositeTextMapPropagator()).Once() - - telemetry.TelemetryFacade = mockTelemetry }, - assert: func() { - handler := NewClientStatsHandler( - WithSpanAttributes(), - WithMetricAttributes(), - ) - s.NotNil(handler) + assert: func(t *mockstelemetry.Telemetry, c *mocksconfig.Config) { + s.NotNil(NewClientStatsHandler(c, t)) }, }, } @@ -153,9 +131,12 @@ func (s *HandlerTestSuite) TestClientStatsHandler() { for _, test := range tests { s.Run(test.name, func() { mockTelemetry := mockstelemetry.NewTelemetry(s.T()) + mockConfig := mocksconfig.NewConfig(s.T()) - test.setup(mockTelemetry) - test.assert() + if test.setup != nil { + test.setup(mockTelemetry, mockConfig) + } + test.assert(mockTelemetry, mockConfig) }) } } diff --git a/telemetry/instrumentation/http/config.go b/telemetry/instrumentation/http/config.go new file mode 100644 index 000000000..d5345a96c --- /dev/null +++ b/telemetry/instrumentation/http/config.go @@ -0,0 +1,51 @@ +package http + +import ( + "fmt" + + "go.opentelemetry.io/otel/attribute" + + "github.com/goravel/framework/contracts/http" +) + +// Filter determines whether a specific request should be traced. +// Return true to trace the request, or false to exclude it. +type Filter func(ctx http.Context) bool + +// SpanNameFormatter allows customizing the span name. +type SpanNameFormatter func(route string, ctx http.Context) string + +// Option applies configuration to the server instrumentation. +type Option func(*ServerConfig) + +// ServerConfig maps to "telemetry.instrumentation.http_server". +type ServerConfig struct { + Enabled bool `json:"enabled"` + ExcludedPaths []string `json:"excluded_paths"` + ExcludedMethods []string `json:"excluded_methods"` + Filters []Filter `json:"-"` + SpanNameFormatter SpanNameFormatter `json:"-"` + MetricAttributes []attribute.KeyValue `json:"-"` +} + +func WithFilter(f Filter) Option { + return func(c *ServerConfig) { + c.Filters = append(c.Filters, f) + } +} + +func WithSpanNameFormatter(f SpanNameFormatter) Option { + return func(c *ServerConfig) { + c.SpanNameFormatter = f + } +} + +func WithMetricAttributes(attrs ...attribute.KeyValue) Option { + return func(c *ServerConfig) { + c.MetricAttributes = append(c.MetricAttributes, attrs...) + } +} + +func defaultSpanNameFormatter(route string, ctx http.Context) string { + return fmt.Sprintf("%s %s", ctx.Request().Method(), route) +} diff --git a/telemetry/instrumentation/http/middleware.go b/telemetry/instrumentation/http/middleware.go new file mode 100644 index 000000000..25ea9cfd9 --- /dev/null +++ b/telemetry/instrumentation/http/middleware.go @@ -0,0 +1,200 @@ +package http + +import ( + "fmt" + "time" + + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.37.0" + "go.opentelemetry.io/otel/trace" + + contractsconfig "github.com/goravel/framework/contracts/config" + "github.com/goravel/framework/contracts/http" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" + "github.com/goravel/framework/support/color" + "github.com/goravel/framework/telemetry" +) + +const ( + instrumentationName = "github.com/goravel/framework/telemetry/instrumentation/http" + + metricRequestDuration = "http.server.request.duration" + metricRequestBodySize = "http.server.request.body.size" + metricResponseBodySize = "http.server.response.body.size" + + unitSeconds = "s" + unitBytes = "By" +) + +// Telemetry creates HTTP server telemetry middleware that instruments incoming +// requests with tracing and metrics. The optional opts parameters allow +// customizing the server configuration (such as span naming and enabling or +// disabling instrumentation). It returns an http.Middleware that propagates +// context, records spans and metrics when telemetry is enabled, and otherwise +// transparently passes requests through when telemetry is disabled or not +// initialized. +func Telemetry(config contractsconfig.Config, telemetry contractstelemetry.Telemetry, opts ...Option) http.Middleware { + if config == nil || telemetry == nil { + return func(ctx http.Context) { + ctx.Request().Next() + } + } + + var cfg ServerConfig + if err := config.UnmarshalKey("telemetry.instrumentation.http_server", &cfg); err != nil { + color.Warningln("Failed to load http server telemetry instrumentation config:", err) + return func(ctx http.Context) { + ctx.Request().Next() + } + } + + for _, opt := range opts { + opt(&cfg) + } + + if !cfg.Enabled { + return func(ctx http.Context) { + ctx.Request().Next() + } + } + + if cfg.SpanNameFormatter == nil { + cfg.SpanNameFormatter = defaultSpanNameFormatter + } + + meter := telemetry.Meter(instrumentationName) + durationHist, _ := meter.Float64Histogram(metricRequestDuration, metric.WithUnit(unitSeconds), metric.WithDescription("Duration of HTTP server requests")) + requestSizeHist, _ := meter.Int64Histogram(metricRequestBodySize, metric.WithUnit(unitBytes), metric.WithDescription("Size of HTTP server request body")) + responseSizeHist, _ := meter.Int64Histogram(metricResponseBodySize, metric.WithUnit(unitBytes), metric.WithDescription("Size of HTTP server response body")) + + excludedPaths := make(map[string]bool, len(cfg.ExcludedPaths)) + for _, p := range cfg.ExcludedPaths { + excludedPaths[p] = true + } + excludedMethods := make(map[string]bool, len(cfg.ExcludedMethods)) + for _, m := range cfg.ExcludedMethods { + excludedMethods[m] = true + } + + h := &MiddlewareHandler{ + tracer: telemetry.Tracer(instrumentationName), + propagator: telemetry.Propagator(), + durationHist: durationHist, + requestSizeHist: requestSizeHist, + responseSizeHist: responseSizeHist, + cfg: cfg, + excludedPaths: excludedPaths, + excludedMethods: excludedMethods, + } + + return h.Handle +} + +type MiddlewareHandler struct { + // Telemetry components + tracer trace.Tracer + propagator propagation.TextMapPropagator + durationHist metric.Float64Histogram + requestSizeHist metric.Int64Histogram + responseSizeHist metric.Int64Histogram + + cfg ServerConfig + excludedPaths map[string]bool + excludedMethods map[string]bool +} + +func (r *MiddlewareHandler) Handle(ctx http.Context) { + req := ctx.Request() + + routePath := req.OriginPath() + if routePath == "" { + routePath = req.Path() + } + + if r.excludedPaths[routePath] || r.excludedMethods[req.Method()] { + req.Next() + return + } + + for _, f := range r.cfg.Filters { + if !f(ctx) { + req.Next() + return + } + } + + start := time.Now() + parentCtx := r.propagator.Extract(ctx.Context(), propagation.HeaderCarrier(req.Headers())) + spanName := r.cfg.SpanNameFormatter(routePath, ctx) + + scheme := "http" + if proto := req.Header("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + + baseAttrs := []telemetry.KeyValue{ + semconv.HTTPRequestMethodKey.String(req.Method()), + semconv.HTTPRoute(routePath), + semconv.URLScheme(scheme), + semconv.ServerAddress(req.Host()), + semconv.ClientAddress(req.Ip()), + semconv.UserAgentOriginal(req.Header("User-Agent")), + } + + baseAttrs = append(baseAttrs, r.cfg.MetricAttributes...) + + spanCtx, span := r.tracer.Start(parentCtx, spanName, + telemetry.WithAttributes(baseAttrs...), + telemetry.WithSpanKind(telemetry.SpanKindServer), + ) + defer span.End() + + ctx.WithContext(spanCtx) + + func() { + defer func() { + if rec := recover(); rec != nil { + err := fmt.Errorf("panic: %v", rec) + span.RecordError(err) + span.SetStatus(codes.Error, "Internal Server Error") + + metricAttrs := metric.WithAttributes(append(baseAttrs, semconv.HTTPResponseStatusCode(500))...) + + r.durationHist.Record(spanCtx, time.Since(start).Seconds(), metricAttrs) + r.requestSizeHist.Record(spanCtx, getRequestSize(req), metricAttrs) + r.responseSizeHist.Record(spanCtx, 0, metricAttrs) + + panic(rec) + } + }() + req.Next() + }() + + status := ctx.Response().Origin().Status() + + span.SetAttributes(semconv.HTTPResponseStatusCode(status)) + + if status >= 500 { + span.SetStatus(codes.Error, "") + } + + if err := ctx.Err(); err != nil { + span.RecordError(err) + } + + metricAttrs := metric.WithAttributes(append(baseAttrs, semconv.HTTPResponseStatusCode(status))...) + + r.durationHist.Record(spanCtx, time.Since(start).Seconds(), metricAttrs) + r.requestSizeHist.Record(spanCtx, getRequestSize(req), metricAttrs) + r.responseSizeHist.Record(spanCtx, int64(ctx.Response().Origin().Size()), metricAttrs) +} + +func getRequestSize(req http.ContextRequest) int64 { + size := req.Origin().ContentLength + if size < 0 { + return 0 + } + return size +} diff --git a/telemetry/instrumentation/http/middleware_test.go b/telemetry/instrumentation/http/middleware_test.go new file mode 100644 index 000000000..5409048e6 --- /dev/null +++ b/telemetry/instrumentation/http/middleware_test.go @@ -0,0 +1,310 @@ +package http + +import ( + "bytes" + "context" + nethttp "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + tracenoop "go.opentelemetry.io/otel/trace/noop" + + contractsconfig "github.com/goravel/framework/contracts/config" + contractshttp "github.com/goravel/framework/contracts/http" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" + mocksconfig "github.com/goravel/framework/mocks/config" + mockstelemetry "github.com/goravel/framework/mocks/telemetry" +) + +type MiddlewareTestSuite struct { + suite.Suite +} + +func TestMiddlewareTestSuite(t *testing.T) { + suite.Run(t, new(MiddlewareTestSuite)) +} + +func (s *MiddlewareTestSuite) TestTelemetry() { + defaultTelemetrySetup := func(mockTelemetry *mockstelemetry.Telemetry) { + mockTelemetry.EXPECT().Tracer(instrumentationName).Return(tracenoop.NewTracerProvider().Tracer("test")).Once() + mockTelemetry.EXPECT().Meter(instrumentationName).Return(metricnoop.NewMeterProvider().Meter("test")).Once() + mockTelemetry.EXPECT().Propagator().Return(propagation.NewCompositeTextMapPropagator()).Once() + } + + tests := []struct { + name string + configSetup func(*mocksconfig.Config) + telemetrySetup func(*mockstelemetry.Telemetry) + opts []Option + handler nethttp.HandlerFunc + requestPath string + expectPanic bool + }{ + { + name: "Success: Request is traced and metrics recorded", + requestPath: "/users", + configSetup: func(mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().UnmarshalKey("telemetry.instrumentation.http_server", mock.Anything). + Run(func(_ string, cfg any) { + c := cfg.(*ServerConfig) + c.Enabled = true + }).Return(nil).Once() + }, + telemetrySetup: defaultTelemetrySetup, + handler: func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.WriteHeader(nethttp.StatusOK) + _, _ = w.Write([]byte("OK")) + }, + }, + { + name: "Success: Custom Filter blocks tracing", + requestPath: "/admin", + opts: []Option{ + WithFilter(func(ctx contractshttp.Context) bool { + // Don't trace admin routes + return ctx.Request().OriginPath() != "/admin" + }), + }, + configSetup: func(mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().UnmarshalKey("telemetry.instrumentation.http_server", mock.Anything). + Run(func(_ string, cfg any) { + c := cfg.(*ServerConfig) + c.Enabled = true + }).Return(nil).Once() + }, + telemetrySetup: defaultTelemetrySetup, + handler: func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.WriteHeader(nethttp.StatusOK) + }, + }, + { + name: "Ignored: Excluded path is skipped", + requestPath: "/health", + configSetup: func(mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().UnmarshalKey("telemetry.instrumentation.http_server", mock.Anything). + Run(func(_ string, cfg interface{}) { + c := cfg.(*ServerConfig) + c.Enabled = true + c.ExcludedPaths = []string{"/health"} + }).Return(nil).Once() + }, + telemetrySetup: defaultTelemetrySetup, + handler: func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.WriteHeader(nethttp.StatusOK) + }, + }, + { + name: "Ignored: Disabled via config", + requestPath: "/users", + configSetup: func(mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().UnmarshalKey("telemetry.instrumentation.http_server", mock.Anything). + Run(func(_ string, cfg interface{}) { + c := cfg.(*ServerConfig) + c.Enabled = false + }).Return(nil).Once() + }, + telemetrySetup: func(mockTelemetry *mockstelemetry.Telemetry) { + // If disabled, Tracer/Meter should NOT be initialized + }, + handler: func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.WriteHeader(nethttp.StatusOK) + }, + }, + { + name: "Panic: Metrics recorded as 500 and panic re-thrown", + requestPath: "/crash", + expectPanic: true, + configSetup: func(mockConfig *mocksconfig.Config) { + mockConfig.EXPECT().UnmarshalKey("telemetry.instrumentation.http_server", mock.Anything). + Run(func(_ string, cfg any) { + c := cfg.(*ServerConfig) + c.Enabled = true + }).Return(nil).Once() + }, + telemetrySetup: defaultTelemetrySetup, + handler: func(w nethttp.ResponseWriter, r *nethttp.Request) { + panic("server crash") + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + mockConfig := mocksconfig.NewConfig(s.T()) + mockTelemetry := mockstelemetry.NewTelemetry(s.T()) + + tt.configSetup(mockConfig) + tt.telemetrySetup(mockTelemetry) + + handler := testMiddleware(mockConfig, mockTelemetry, tt.handler, tt.opts...) + + if tt.expectPanic { + req := httptest.NewRequest("GET", tt.requestPath, nil) + w := httptest.NewRecorder() + s.Panics(func() { + handler.ServeHTTP(w, req) + }) + } else { + server := httptest.NewServer(handler) + defer server.Close() + + client := &nethttp.Client{} + resp, err := client.Get(server.URL + tt.requestPath) + s.NoError(err) + if resp != nil { + s.NoError(resp.Body.Close()) + } + } + }) + } +} + +func testMiddleware(config contractsconfig.Config, telemetry contractstelemetry.Telemetry, next nethttp.Handler, opts ...Option) nethttp.Handler { + mw := Telemetry(config, telemetry, opts...) + return nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + ctx := NewTestContext(r.Context(), next, w, r) + mw(ctx) + }) +} + +type TestContext struct { + ctx context.Context + next nethttp.Handler + request *nethttp.Request + writer *TestResponseWriter +} + +func NewTestContext(ctx context.Context, next nethttp.Handler, w nethttp.ResponseWriter, r *nethttp.Request) *TestContext { + return &TestContext{ + ctx: ctx, + next: next, + request: r, + writer: &TestResponseWriter{ResponseWriter: w, status: 200}, + } +} + +func (c *TestContext) Request() contractshttp.ContextRequest { + return NewTestRequest(c) +} + +func (c *TestContext) Response() contractshttp.ContextResponse { + return NewTestResponse(c) +} + +func (c *TestContext) WithContext(ctx context.Context) { + c.ctx = ctx + c.request = c.request.WithContext(ctx) +} + +func (c *TestContext) Context() context.Context { + return c.ctx +} + +func (c *TestContext) Err() error { + return c.ctx.Err() +} + +func (c *TestContext) Deadline() (deadline time.Time, ok bool) { return c.ctx.Deadline() } +func (c *TestContext) Done() <-chan struct{} { return c.ctx.Done() } +func (c *TestContext) Value(key any) any { return c.ctx.Value(key) } +func (c *TestContext) WithValue(key any, value any) { c.ctx = context.WithValue(c.ctx, key, value) } + +type TestRequest struct { + contractshttp.ContextRequest + ctx *TestContext +} + +func NewTestRequest(ctx *TestContext) *TestRequest { + return &TestRequest{ctx: ctx} +} + +func (r *TestRequest) Next() { + r.ctx.next.ServeHTTP(r.ctx.writer, r.ctx.request) +} + +func (r *TestRequest) Method() string { + return r.ctx.request.Method +} + +func (r *TestRequest) Path() string { + return r.ctx.request.URL.Path +} + +func (r *TestRequest) OriginPath() string { + return r.ctx.request.URL.Path +} + +func (r *TestRequest) Headers() nethttp.Header { + return r.ctx.request.Header +} + +func (r *TestRequest) Header(key string, defaultValue ...string) string { + val := r.ctx.request.Header.Get(key) + if val == "" && len(defaultValue) > 0 { + return defaultValue[0] + } + return val +} + +func (r *TestRequest) Host() string { + return r.ctx.request.Host +} + +func (r *TestRequest) Ip() string { + return "127.0.0.1" +} + +func (r *TestRequest) Origin() *nethttp.Request { + return r.ctx.request +} + +type TestResponse struct { + contractshttp.ContextResponse + ctx *TestContext +} + +func NewTestResponse(ctx *TestContext) *TestResponse { + return &TestResponse{ctx: ctx} +} + +func (r *TestResponse) Origin() contractshttp.ResponseOrigin { + return r.ctx.writer +} + +type TestResponseWriter struct { + nethttp.ResponseWriter + status int + size int +} + +func (w *TestResponseWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *TestResponseWriter) Write(b []byte) (int, error) { + n, err := w.ResponseWriter.Write(b) + w.size += n + return n, err +} + +func (w *TestResponseWriter) Status() int { + return w.status +} + +func (w *TestResponseWriter) Size() int { + return w.size +} + +func (w *TestResponseWriter) Header() nethttp.Header { + return w.ResponseWriter.Header() +} + +func (w *TestResponseWriter) Body() *bytes.Buffer { + return nil +} diff --git a/telemetry/instrumentation/http/transport.go b/telemetry/instrumentation/http/transport.go new file mode 100644 index 000000000..56c8ee0f3 --- /dev/null +++ b/telemetry/instrumentation/http/transport.go @@ -0,0 +1,38 @@ +package http + +import ( + "net/http" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + contractsconfig "github.com/goravel/framework/contracts/config" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" +) + +// NewTransport returns an http.RoundTripper instrumented with OpenTelemetry. +// It wraps the provided base RoundTripper with otelhttp using the configured +// telemetry facade's tracer provider, meter provider, and propagator. +// +// If telemetry is nil, no instrumentation is applied. In that case, +// http.DefaultTransport is returned when base is nil; otherwise the provided +// base RoundTripper is returned. +func NewTransport(config contractsconfig.Config, telemetry contractstelemetry.Telemetry, base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + + if config == nil || telemetry == nil { + return base + } + + if !config.GetBool("telemetry.instrumentation.http_client", true) { + return base + } + + return otelhttp.NewTransport( + base, + otelhttp.WithTracerProvider(telemetry.TracerProvider()), + otelhttp.WithMeterProvider(telemetry.MeterProvider()), + otelhttp.WithPropagators(telemetry.Propagator()), + ) +} diff --git a/telemetry/instrumentation/http/transport_test.go b/telemetry/instrumentation/http/transport_test.go new file mode 100644 index 000000000..8bb9b8014 --- /dev/null +++ b/telemetry/instrumentation/http/transport_test.go @@ -0,0 +1,81 @@ +package http + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + tracenoop "go.opentelemetry.io/otel/trace/noop" + + contractsconfig "github.com/goravel/framework/contracts/config" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" + mocksconfig "github.com/goravel/framework/mocks/config" + mockstelemetry "github.com/goravel/framework/mocks/telemetry" +) + +type TransportTestSuite struct { + suite.Suite +} + +func TestTransportTestSuite(t *testing.T) { + suite.Run(t, new(TransportTestSuite)) +} + +func (s *TransportTestSuite) TestNewTransport() { + baseTransport := &http.Transport{} + + tests := []struct { + name string + setup func(t *testing.T) (contractstelemetry.Telemetry, contractsconfig.Config) + expectWrapped bool + }{ + { + name: "Fallback: Returns base when Config is nil", + setup: func(t *testing.T) (contractstelemetry.Telemetry, contractsconfig.Config) { + return mockstelemetry.NewTelemetry(t), nil + }, + expectWrapped: false, + }, + { + name: "Fallback: Returns base when Telemetry is nil", + setup: func(t *testing.T) (contractstelemetry.Telemetry, contractsconfig.Config) { + return nil, mocksconfig.NewConfig(t) + }, + expectWrapped: false, + }, + { + name: "Kill Switch: Returns base when http_client is disabled", + setup: func(t *testing.T) (contractstelemetry.Telemetry, contractsconfig.Config) { + mockConfig := mocksconfig.NewConfig(t) + mockConfig.EXPECT().GetBool("telemetry.instrumentation.http_client", true).Return(false).Once() + return mockstelemetry.NewTelemetry(t), mockConfig + }, + expectWrapped: false, + }, + { + name: "Success: Returns wrapped transport when enabled", + setup: func(t *testing.T) (contractstelemetry.Telemetry, contractsconfig.Config) { + mockConfig := mocksconfig.NewConfig(t) + mockConfig.EXPECT().GetBool("telemetry.instrumentation.http_client", true).Return(true).Once() + + mockTelemetry := mockstelemetry.NewTelemetry(t) + mockTelemetry.EXPECT().TracerProvider().Return(tracenoop.NewTracerProvider()).Once() + mockTelemetry.EXPECT().MeterProvider().Return(metricnoop.NewMeterProvider()).Once() + mockTelemetry.EXPECT().Propagator().Return(propagation.NewCompositeTextMapPropagator()).Once() + + return mockTelemetry, mockConfig + }, + expectWrapped: true, + }, + } + + for _, test := range tests { + s.Run(test.name, func() { + telemetry, config := test.setup(s.T()) + result := NewTransport(config, telemetry, baseTransport) + s.Equal(test.expectWrapped, baseTransport != result, "Transport wrapping mismatch") + }) + } +} diff --git a/telemetry/instrumentation/log/channel.go b/telemetry/instrumentation/log/channel.go index 961ae61d5..a90593be3 100644 --- a/telemetry/instrumentation/log/channel.go +++ b/telemetry/instrumentation/log/channel.go @@ -1,31 +1,40 @@ package log import ( - "github.com/goravel/framework/contracts/log" - "github.com/goravel/framework/errors" - "github.com/goravel/framework/telemetry" + contractsconfig "github.com/goravel/framework/contracts/config" + contractslog "github.com/goravel/framework/contracts/log" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" ) -const defaultInstrumentationName = "github.com/goravel/framework/telemetry/instrumentation/log" +const DefaultInstrumentationName = "github.com/goravel/framework/telemetry/instrumentation/log" -type TelemetryChannel struct{} +type TelemetryChannel struct { + config contractsconfig.Config + resolver contractstelemetry.Resolver +} -func NewTelemetryChannel() *TelemetryChannel { - return &TelemetryChannel{} +func NewTelemetryChannel(config contractsconfig.Config, telemetry contractstelemetry.Telemetry) *TelemetryChannel { + return NewLazyTelemetryChannel(config, func() contractstelemetry.Telemetry { + return telemetry + }) } -func (r *TelemetryChannel) Handle(channelPath string) (log.Handler, error) { - if telemetry.TelemetryFacade == nil { - return nil, errors.TelemetryFacadeNotSet +func NewLazyTelemetryChannel(config contractsconfig.Config, resolver contractstelemetry.Resolver) *TelemetryChannel { + return &TelemetryChannel{ + config: config, + resolver: resolver, } +} - config := telemetry.ConfigFacade - if config == nil { - return nil, errors.ConfigFacadeNotSet +func (r *TelemetryChannel) Handle(channelPath string) (contractslog.Handler, error) { + if r.config == nil || !r.config.GetBool("telemetry.instrumentation.log", false) { + return &handler{enabled: false}, nil } - instrumentName := config.GetString(channelPath+".instrument_name", defaultInstrumentationName) + instrumentName := r.config.GetString(channelPath+".instrument_name", DefaultInstrumentationName) return &handler{ - logger: telemetry.TelemetryFacade.Logger(instrumentName), + resolver: r.resolver, + enabled: true, + instrumentName: instrumentName, }, nil } diff --git a/telemetry/instrumentation/log/channel_test.go b/telemetry/instrumentation/log/channel_test.go index 8482b0dd8..87da40d43 100644 --- a/telemetry/instrumentation/log/channel_test.go +++ b/telemetry/instrumentation/log/channel_test.go @@ -4,83 +4,74 @@ import ( "testing" "github.com/stretchr/testify/suite" - "go.opentelemetry.io/otel/log/noop" - "github.com/goravel/framework/errors" + contractslog "github.com/goravel/framework/contracts/log" mocksconfig "github.com/goravel/framework/mocks/config" mockstelemetry "github.com/goravel/framework/mocks/telemetry" - "github.com/goravel/framework/telemetry" ) type TelemetryChannelTestSuite struct { suite.Suite - mockConfig *mocksconfig.Config - mockTelemetry *mockstelemetry.Telemetry } func TestTelemetryChannelTestSuite(t *testing.T) { suite.Run(t, new(TelemetryChannelTestSuite)) } -func (s *TelemetryChannelTestSuite) SetupTest() { - s.mockConfig = mocksconfig.NewConfig(s.T()) - s.mockTelemetry = mockstelemetry.NewTelemetry(s.T()) - - telemetry.ConfigFacade = s.mockConfig - telemetry.TelemetryFacade = s.mockTelemetry -} - -func (s *TelemetryChannelTestSuite) TearDownTest() { - telemetry.ConfigFacade = nil - telemetry.TelemetryFacade = nil -} - -func (s *TelemetryChannelTestSuite) TestHandle_Success_DefaultName() { - channelPath := "logging.channels.otel" - s.mockConfig.EXPECT().GetString(channelPath+".instrument_name", defaultInstrumentationName).Return(defaultInstrumentationName).Once() - - s.mockTelemetry.On("Logger", defaultInstrumentationName).Return(noop.NewLoggerProvider().Logger("test")).Once() - - channel := NewTelemetryChannel() - h, err := channel.Handle(channelPath) - - s.NoError(err) - s.NotNil(h) - s.mockTelemetry.AssertExpectations(s.T()) -} - -func (s *TelemetryChannelTestSuite) TestHandle_Success_CustomName() { - channelPath := "logging.channels.otel" - customName := "my-service-logs" - - s.mockConfig.EXPECT().GetString(channelPath+".instrument_name", defaultInstrumentationName).Return(customName).Once() - - s.mockTelemetry.On("Logger", customName).Return(noop.NewLoggerProvider().Logger("test")).Once() - - channel := NewTelemetryChannel() - h, err := channel.Handle(channelPath) - - s.NoError(err) - s.NotNil(h) - s.mockTelemetry.AssertExpectations(s.T()) -} - -func (s *TelemetryChannelTestSuite) TestHandle_Error_TelemetryFacadeNotSet() { - telemetry.TelemetryFacade = nil - - channel := NewTelemetryChannel() - h, err := channel.Handle("logging.channels.otel") - - s.ErrorIs(err, errors.TelemetryFacadeNotSet) - s.Nil(h) -} - -func (s *TelemetryChannelTestSuite) TestHandle_Error_ConfigFacadeNotSet() { - telemetry.ConfigFacade = nil - - channel := NewTelemetryChannel() - h, err := channel.Handle("logging.channels.otel") - - s.ErrorIs(err, errors.ConfigFacadeNotSet) - s.Nil(h) +func (s *TelemetryChannelTestSuite) TestHandle() { + const ( + channelPath = "logging.channels.otel" + telemetryKey = "telemetry.instrumentation.log" + ) + + tests := []struct { + name string + setup func(m *mocksconfig.Config) + shouldBeEnabled bool + expectedInstName string + }{ + { + name: "Success: Telemetry enabled with custom name", + setup: func(m *mocksconfig.Config) { + m.EXPECT().GetBool(telemetryKey, false).Return(true).Once() + m.EXPECT().GetString(channelPath+".instrument_name", DefaultInstrumentationName). + Return("custom-app-logger").Once() + }, + shouldBeEnabled: true, + expectedInstName: "custom-app-logger", + }, + { + name: "Success: Telemetry disabled via config", + setup: func(m *mocksconfig.Config) { + m.EXPECT().GetBool(telemetryKey, false).Return(false).Once() + }, + shouldBeEnabled: false, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + mockConfig := mocksconfig.NewConfig(s.T()) + mockTelemetry := mockstelemetry.NewTelemetry(s.T()) + + tt.setup(mockConfig) + + channel := NewTelemetryChannel(mockConfig, mockTelemetry) + channelHandler, err := channel.Handle(channelPath) + s.NoError(err) + s.NotNil(channelHandler) + + s.Equal(tt.shouldBeEnabled, channelHandler.Enabled(contractslog.LevelInfo)) + + if tt.shouldBeEnabled { + impl, ok := channelHandler.(*handler) + s.True(ok, "Returned handler must be of type *handler") + + s.Equal(tt.expectedInstName, impl.instrumentName, "Instrumentation name should match config") + + s.NotNil(impl.resolver, "Handler resolver should not be nil") + s.Equal(mockTelemetry, impl.resolver(), "Resolver should return the injected telemetry service") + } + }) + } } diff --git a/telemetry/instrumentation/log/handler.go b/telemetry/instrumentation/log/handler.go index 99998eb29..8e6e8a864 100644 --- a/telemetry/instrumentation/log/handler.go +++ b/telemetry/instrumentation/log/handler.go @@ -2,25 +2,37 @@ package log import ( "context" + "sync" "time" otellog "go.opentelemetry.io/otel/log" contractslog "github.com/goravel/framework/contracts/log" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" ) var _ contractslog.Handler = (*handler)(nil) type handler struct { - logger otellog.Logger + resolver contractstelemetry.Resolver // The un-executed function + telemetry contractstelemetry.Telemetry // The cached instance + enabled bool + instrumentName string + logger otellog.Logger + mu sync.Mutex } func (r *handler) Enabled(level contractslog.Level) bool { - return true + return r.enabled } func (r *handler) Handle(entry contractslog.Entry) error { - if r.logger == nil { + if !r.enabled { + return nil + } + + logger := r.getLogger() + if logger == nil { return nil } @@ -29,11 +41,30 @@ func (r *handler) Handle(entry contractslog.Entry) error { ctx = context.Background() } - r.logger.Emit(ctx, r.convertEntry(entry)) + logger.Emit(ctx, r.convertEntry(entry)) return nil } +func (r *handler) getLogger() otellog.Logger { + r.mu.Lock() + defer r.mu.Unlock() + + if r.logger != nil { + return r.logger + } + + if r.telemetry == nil && r.resolver != nil { + r.telemetry = r.resolver() + } + + if r.telemetry != nil { + r.logger = r.telemetry.Logger(r.instrumentName) + } + + return r.logger +} + func (r *handler) convertEntry(e contractslog.Entry) otellog.Record { var record otellog.Record record.SetTimestamp(e.Time()) diff --git a/telemetry/instrumentation/log/handler_test.go b/telemetry/instrumentation/log/handler_test.go index 6be9865e2..e8160ef59 100644 --- a/telemetry/instrumentation/log/handler_test.go +++ b/telemetry/instrumentation/log/handler_test.go @@ -10,21 +10,35 @@ import ( "go.opentelemetry.io/otel/log/logtest" contractslog "github.com/goravel/framework/contracts/log" + contractstelemetry "github.com/goravel/framework/contracts/telemetry" + mockstelemetry "github.com/goravel/framework/mocks/telemetry" ) type HandlerTestSuite struct { suite.Suite - recorder *logtest.Recorder - handler *handler - loggerName string - ctx context.Context - now time.Time + recorder *logtest.Recorder + handler *handler + loggerName string + ctx context.Context + now time.Time + mockTelemetry *mockstelemetry.Telemetry +} + +func TestHandlerTestSuite(t *testing.T) { + suite.Run(t, new(HandlerTestSuite)) } func (s *HandlerTestSuite) SetupTest() { s.loggerName = "test-logger" s.recorder = logtest.NewRecorder() + s.mockTelemetry = mockstelemetry.NewTelemetry(s.T()) + s.handler = &handler{ + enabled: true, + instrumentName: s.loggerName, + resolver: func() contractstelemetry.Telemetry { + return s.mockTelemetry + }, logger: s.recorder.Logger(s.loggerName), } s.now = time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) @@ -33,6 +47,45 @@ func (s *HandlerTestSuite) SetupTest() { s.ctx = context.WithValue(context.Background(), ctxKey("request_id"), "req-123") } +func (s *HandlerTestSuite) TestHandle_Lazy_Success() { + s.handler.logger = nil + s.handler.telemetry = nil + + s.mockTelemetry.EXPECT().Logger(s.loggerName).Return(s.recorder.Logger(s.loggerName)).Once() + + entry := &TestEntry{ + ctx: context.Background(), + level: contractslog.LevelInfo, + time: s.now, + } + + err := s.handler.Handle(entry) + s.NoError(err) + + s.mockTelemetry.AssertExpectations(s.T()) + s.NotNil(s.handler.logger, "Logger should be initialized") + s.NotNil(s.handler.telemetry, "Telemetry instance should be cached") +} + +func (s *HandlerTestSuite) TestHandle_Lazy_Nil() { + s.handler.logger = nil + s.handler.telemetry = nil + s.handler.resolver = func() contractstelemetry.Telemetry { + return nil + } + + entry := &TestEntry{ + ctx: context.Background(), + level: contractslog.LevelInfo, + } + + err := s.handler.Handle(entry) + + s.NoError(err) + s.Nil(s.handler.logger, "Logger should remain nil") + s.Nil(s.handler.telemetry, "Telemetry should remain nil") +} + func (s *HandlerTestSuite) TestEnabled() { s.True(s.handler.Enabled(contractslog.LevelDebug)) s.True(s.handler.Enabled(contractslog.LevelInfo)) @@ -111,7 +164,6 @@ func (s *HandlerTestSuite) TestHandle() { }, data: map[string]any{ "user_id": 42, - "active": true, }, }, expected: logtest.Record{ @@ -123,7 +175,6 @@ func (s *HandlerTestSuite) TestHandle() { Attributes: []log.KeyValue{ log.String("foo", "bar"), log.Int64("user_id", 42), - log.Bool("active", true), }, }, }, @@ -134,14 +185,9 @@ func (s *HandlerTestSuite) TestHandle() { level: contractslog.LevelWarning, time: s.now, user: map[string]any{ - "id": 1, "role": "admin", }, tags: []string{"critical", "auth"}, - request: map[string]any{ - "method": "GET", - "url": "/login", - }, }, expected: logtest.Record{ Context: s.ctx, @@ -151,17 +197,12 @@ func (s *HandlerTestSuite) TestHandle() { Body: log.StringValue(""), Attributes: []log.KeyValue{ log.Map("user", - log.Int64("id", 1), log.String("role", "admin"), ), log.Slice("tags", log.StringValue("critical"), log.StringValue("auth"), ), - log.Map("request", - log.String("method", "GET"), - log.String("url", "/login"), - ), }, }, }, @@ -184,11 +225,8 @@ func (s *HandlerTestSuite) TestHandle() { for _, tt := range tests { s.Run(tt.name, func() { - // Reset the recorder for each test case s.recorder = logtest.NewRecorder() - s.handler = &handler{ - logger: s.recorder.Logger(s.loggerName), - } + s.handler.logger = s.recorder.Logger(s.loggerName) err := s.handler.Handle(tt.entry) s.NoError(err) @@ -213,10 +251,6 @@ func (s *HandlerTestSuite) normalizeObservedTimestamp(result logtest.Recording) } } -func TestHandlerTestSuite(t *testing.T) { - suite.Run(t, new(HandlerTestSuite)) -} - type TestEntry struct { ctx context.Context level contractslog.Level diff --git a/telemetry/service_provider.go b/telemetry/service_provider.go index cd8fd6395..a8f8fa063 100644 --- a/telemetry/service_provider.go +++ b/telemetry/service_provider.go @@ -2,17 +2,10 @@ package telemetry import ( "github.com/goravel/framework/contracts/binding" - "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/foundation" - "github.com/goravel/framework/contracts/telemetry" "github.com/goravel/framework/errors" ) -var ( - TelemetryFacade telemetry.Telemetry - ConfigFacade config.Config -) - type ServiceProvider struct { } @@ -43,6 +36,4 @@ func (r *ServiceProvider) Register(app foundation.Application) { } func (r *ServiceProvider) Boot(app foundation.Application) { - // TelemetryFacade = app.MakeTelemetry() - ConfigFacade = app.MakeConfig() } diff --git a/telemetry/setup/stubs.go b/telemetry/setup/stubs.go index 5d553fdc6..36239998d 100644 --- a/telemetry/setup/stubs.go +++ b/telemetry/setup/stubs.go @@ -58,7 +58,7 @@ func init() { // // The name of the exporter definition in the "exporters" section below. // Set to "" to disable tracing. - "exporter": config.Env("OTEL_TRACES_EXPORTER", "otlptrace"), + "exporter": config.Env("OTEL_TRACES_EXPORTER"), // Sampler Configuration // @@ -89,7 +89,7 @@ func init() { // // The name of the exporter definition in the "exporters" section below. // Set to "" to disable metrics. - "exporter": config.Env("OTEL_METRICS_EXPORTER", "otlpmetric"), + "exporter": config.Env("OTEL_METRICS_EXPORTER"), // Reader Configuration // @@ -115,7 +115,7 @@ func init() { // // The name of the exporter definition in the "exporters" section below. // Set to "" to disable OTel logging. - "exporter": config.Env("OTEL_LOGS_EXPORTER", "otlplog"), + "exporter": config.Env("OTEL_LOGS_EXPORTER"), // Processor Configuration // @@ -131,6 +131,56 @@ func init() { }, }, + // Instrumentation Configuration + // + // Configures the automatic instrumentation for specific components. + "instrumentation": map[string]any{ + // HTTP Server Instrumentation + // + // Configures the telemetry middleware for incoming HTTP requests. + "http_server": map[string]any{ + "enabled": config.Env("OTEL_HTTP_SERVER_ENABLED", true), + "excluded_paths": []string{}, // e.g., ["/health", "/metrics"] + "excluded_methods": []string{}, // e.g., ["OPTIONS", "HEAD"] + }, + + // HTTP Client Instrumentation + // + // Configures instrumentation for outgoing HTTP requests made through the + // application's HTTP client facade. This acts as a global kill switch for + // HTTP client telemetry across all clients. + // + // To disable telemetry for a specific client, set + // "http.clients.{client_name}.enable_telemetry" to false for the + // corresponding client configuration. + "http_client": map[string]any{ + "enabled": config.Env("OTEL_HTTP_CLIENT_ENABLED", true), + }, + + // gRPC Server Instrumentation + // + // Configures the instrumentation for incoming gRPC requests to your server. + "grpc_server": map[string]any{ + "enabled": config.Env("OTEL_GRPC_SERVER_ENABLED", true), + }, + + // gRPC Client Instrumentation + // + // Configures the instrumentation for outgoing gRPC calls made by your application. + "grpc_client": map[string]any{ + "enabled": config.Env("OTEL_GRPC_CLIENT_ENABLED", true), + }, + + // Log Instrumentation + // + // Configures the instrumentation for the application logger. + // Disabling this acts as a global kill switch for sending logs to the OTel exporter, + // which can be useful for reducing cost/noise without changing logging config. + "log": map[string]any{ + "enabled": config.Env("OTEL_LOG_ENABLED", true), + }, + }, + // Exporters Configuration // // Defines the details for connecting to external telemetry backends.