diff --git a/go.mod b/go.mod index 136329d5..54636afb 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/prometheus/client_golang v1.19.1 github.com/redis/go-redis/v9 v9.18.0 github.com/stripe/stripe-go/v82 v82.5.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 go.opentelemetry.io/otel/sdk v1.40.0 @@ -184,7 +185,6 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect diff --git a/module/http_middleware_otel.go b/module/http_middleware_otel.go new file mode 100644 index 00000000..7021dd61 --- /dev/null +++ b/module/http_middleware_otel.go @@ -0,0 +1,47 @@ +package module + +import ( + "context" + "net/http" + + "github.com/CrisisTextLine/modular" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +// OTelMiddleware instruments HTTP requests with OpenTelemetry tracing. +type OTelMiddleware struct { + name string + serverName string +} + +// NewOTelMiddleware creates a new OpenTelemetry HTTP tracing middleware. +func NewOTelMiddleware(name, serverName string) *OTelMiddleware { + return &OTelMiddleware{name: name, serverName: serverName} +} + +// Name returns the module name. +func (m *OTelMiddleware) Name() string { return m.name } + +// Init initializes the middleware. +func (m *OTelMiddleware) Init(_ modular.Application) error { return nil } + +// Process wraps the handler with OpenTelemetry HTTP instrumentation. +func (m *OTelMiddleware) Process(next http.Handler) http.Handler { + return otelhttp.NewHandler(next, m.serverName) +} + +// ProvidesServices returns the services provided by this middleware. +func (m *OTelMiddleware) ProvidesServices() []modular.ServiceProvider { + return []modular.ServiceProvider{ + {Name: m.name, Description: "OpenTelemetry HTTP Tracing Middleware", Instance: m}, + } +} + +// RequiresServices returns services required by this middleware. +func (m *OTelMiddleware) RequiresServices() []modular.ServiceDependency { return nil } + +// Start is a no-op for this middleware. +func (m *OTelMiddleware) Start(_ context.Context) error { return nil } + +// Stop is a no-op for this middleware. +func (m *OTelMiddleware) Stop(_ context.Context) error { return nil } diff --git a/module/http_middleware_otel_test.go b/module/http_middleware_otel_test.go new file mode 100644 index 00000000..e3922351 --- /dev/null +++ b/module/http_middleware_otel_test.go @@ -0,0 +1,76 @@ +package module + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewOTelMiddleware(t *testing.T) { + m := NewOTelMiddleware("otel-mw", "workflow-http") + if m.Name() != "otel-mw" { + t.Errorf("expected name 'otel-mw', got %q", m.Name()) + } +} + +func TestOTelMiddleware_Init(t *testing.T) { + app := CreateIsolatedApp(t) + m := NewOTelMiddleware("otel-mw", "workflow-http") + if err := m.Init(app); err != nil { + t.Fatalf("Init failed: %v", err) + } +} + +func TestOTelMiddleware_Process_CallsNext(t *testing.T) { + m := NewOTelMiddleware("otel-mw", "workflow-http") + + nextCalled := false + handler := m.Process(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if !nextCalled { + t.Error("expected next handler to be called") + } + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +func TestOTelMiddleware_ProvidesServices(t *testing.T) { + m := NewOTelMiddleware("otel-mw", "workflow-http") + svcs := m.ProvidesServices() + if len(svcs) != 1 { + t.Fatalf("expected 1 service, got %d", len(svcs)) + } + if svcs[0].Name != "otel-mw" { + t.Errorf("expected service name 'otel-mw', got %q", svcs[0].Name) + } +} + +func TestOTelMiddleware_RequiresServices(t *testing.T) { + m := NewOTelMiddleware("otel-mw", "workflow-http") + if m.RequiresServices() != nil { + t.Error("expected nil dependencies") + } +} + +func TestOTelMiddleware_Start(t *testing.T) { + m := NewOTelMiddleware("otel-mw", "workflow-http") + if err := m.Start(context.TODO()); err != nil { + t.Fatalf("Start failed: %v", err) + } +} + +func TestOTelMiddleware_Stop(t *testing.T) { + m := NewOTelMiddleware("otel-mw", "workflow-http") + if err := m.Stop(context.TODO()); err != nil { + t.Fatalf("Stop failed: %v", err) + } +} diff --git a/plugins/observability/modules.go b/plugins/observability/modules.go index e7f06649..14821ba8 100644 --- a/plugins/observability/modules.go +++ b/plugins/observability/modules.go @@ -16,6 +16,7 @@ func moduleFactories() map[string]plugin.ModuleFactory { "log.collector": logCollectorFactory, "observability.otel": otelTracingFactory, "openapi.generator": openAPIGeneratorFactory, + "http.middleware.otel": otelMiddlewareFactory, } } @@ -116,3 +117,11 @@ func openAPIGeneratorFactory(name string, cfg map[string]any) modular.Module { } return module.NewOpenAPIGenerator(name, genConfig) } + +func otelMiddlewareFactory(name string, cfg map[string]any) modular.Module { + serverName := "workflow-http" + if v, ok := cfg["serverName"].(string); ok && v != "" { + serverName = v + } + return module.NewOTelMiddleware(name, serverName) +} diff --git a/plugins/observability/plugin.go b/plugins/observability/plugin.go index 52d0aeb3..8e1cc242 100644 --- a/plugins/observability/plugin.go +++ b/plugins/observability/plugin.go @@ -36,6 +36,7 @@ func New() *ObservabilityPlugin { "log.collector", "observability.otel", "openapi.generator", + "http.middleware.otel", }, WiringHooks: []string{ "observability.health-endpoints", diff --git a/plugins/observability/schemas.go b/plugins/observability/schemas.go index 23bd5f4b..92554cb0 100644 --- a/plugins/observability/schemas.go +++ b/plugins/observability/schemas.go @@ -76,5 +76,15 @@ func moduleSchemas() []*schema.ModuleSchema { }, DefaultConfig: map[string]any{"title": "Workflow API", "version": "1.0.0"}, }, + { + Type: "http.middleware.otel", + Label: "OTEL HTTP Middleware", + Category: "observability", + Description: "Instruments HTTP requests with OpenTelemetry tracing spans", + ConfigFields: []schema.ConfigFieldDef{ + {Key: "serverName", Label: "Server Name", Type: schema.FieldTypeString, DefaultValue: "workflow-http", Description: "Server name used as the span operation name", Placeholder: "workflow-http"}, + }, + DefaultConfig: map[string]any{"serverName": "workflow-http"}, + }, } }