Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions module/http_middleware_otel.go
Original file line number Diff line number Diff line change
@@ -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 }
76 changes: 76 additions & 0 deletions module/http_middleware_otel_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions plugins/observability/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func moduleFactories() map[string]plugin.ModuleFactory {
"log.collector": logCollectorFactory,
"observability.otel": otelTracingFactory,
"openapi.generator": openAPIGeneratorFactory,
"http.middleware.otel": otelMiddlewareFactory,
}
}

Expand Down Expand Up @@ -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
Comment on lines +123 to +124
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otelMiddlewareFactory dereferences cfg without a nil check. ModuleConfig.Config can be nil when the config: section is omitted (and the engine only injects a map when ConfigDir is set), which would panic at runtime. Treat nil as an empty map (or early-return the default) before reading cfg["serverName"].

Suggested change
if v, ok := cfg["serverName"].(string); ok && v != "" {
serverName = v
if cfg != nil {
if v, ok := cfg["serverName"].(string); ok && v != "" {
serverName = v
}

Copilot uses AI. Check for mistakes.
}
return module.NewOTelMiddleware(name, serverName)
}
1 change: 1 addition & 0 deletions plugins/observability/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func New() *ObservabilityPlugin {
"log.collector",
"observability.otel",
"openapi.generator",
"http.middleware.otel",
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a new module type here requires updating the observability plugin’s unit tests (e.g., TestModuleFactories/TestModuleSchemas/TestModuleTypeCoverage in plugins/observability/plugin_test.go) which currently assert an exact set/count of module types. As-is, go test ./... will fail until the new http.middleware.otel type is included in those expected lists/maps.

Suggested change
"http.middleware.otel",

Copilot uses AI. Check for mistakes.
},
WiringHooks: []string{
"observability.health-endpoints",
Expand Down
10 changes: 10 additions & 0 deletions plugins/observability/schemas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
Comment on lines +79 to +88
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema for http.middleware.otel is missing the Inputs/Outputs fields that all existing http.middleware.* schemas define (see plugins/http/schemas.go). If this type stays as http.middleware.*, consider aligning the schema shape (and likely Category: "middleware") so UI/schema consumers treat it consistently with other middleware types.

Copilot uses AI. Check for mistakes.
}
}
Loading