From d0b85cae0679b44d53fb6774ed74c440d07783b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Tue, 20 Oct 2026 02:02:45 -0300 Subject: [PATCH 1/4] fix(otel): shut the providers down on stop so the last spans and metrics are exported (#257) Nothing kept the SDK providers Start built, so nothing could flush them: a stop dropped the spans still in the batch and the metrics since the last interval (60s), and a short job could exit before its first export. The factory now keeps both providers and exposes Shutdown(ctx), which flushes and stops them at once (one export timeout, not two) and is final: a later Start only warns. The fx module calls it from an OnStop hook and logs a failure instead of returning it, so an unreachable collector does not turn a successful run into exit code 1. oteltest.Collector is an in-process OTLP/gRPC collector, so tests can assert what really reached the exporter instead of dialing localhost:4317. --- .../go.opentelemetry.io/otel/v1/README.md | 61 +++++++++ .../go.opentelemetry.io/otel/v1/metric.go | 8 +- .../go.opentelemetry.io/otel/v1/options.go | 8 +- .../otel/v1/oteltest/collector.go | 105 +++++++++++++++ .../otel/v1/provider_test.go | 126 ++++++++++++++++-- .../go.opentelemetry.io/otel/v1/shutdown.go | 59 ++++++++ .../go.opentelemetry.io/otel/v1/tracer.go | 8 +- fx/README.md | 2 +- .../go.opentelemetry.io/otel/v1/module.go | 18 ++- .../otel/v1/module_test.go | 90 ++++++++++++- go.mod | 2 +- 11 files changed, 467 insertions(+), 20 deletions(-) create mode 100644 factory/contrib/go.opentelemetry.io/otel/v1/README.md create mode 100644 factory/contrib/go.opentelemetry.io/otel/v1/oteltest/collector.go create mode 100644 factory/contrib/go.opentelemetry.io/otel/v1/shutdown.go diff --git a/factory/contrib/go.opentelemetry.io/otel/v1/README.md b/factory/contrib/go.opentelemetry.io/otel/v1/README.md new file mode 100644 index 00000000..2215ae90 --- /dev/null +++ b/factory/contrib/go.opentelemetry.io/otel/v1/README.md @@ -0,0 +1,61 @@ +# OpenTelemetry factory + +Starts and stops the OpenTelemetry tracer and meter providers that every +boost OTel plugin (echo, resty, redis, mongo, grpc, pubsub, sql, mcp, ...) +reports to. + +```go +import otel "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1" +``` + +## Lifecycle + +| Call | What it does | +|---|---| +| `StartTracerProvider(ctx)` / `StartMeterProvider(ctx)` | Builds the SDK provider (OTLP exporter, batching span processor / periodic reader, resource) and registers it globally. Once per process; off when `boost.factory.otel.trace.enabled` / `.metric.enabled` is `false`. | +| `TracerProvider` / `MeterProvider` | What plugins instrument with. Read before Start, they forward to the provider Start registers. | +| `Shutdown(ctx)` | Exports what the providers still hold (the span batch, the metrics since the last export) and stops both, at once, within `ctx`; returns both errors joined. Final: after it nothing is exported, a Start only logs a warning, and a second Shutdown returns `nil`. | + +In an fx app, include `fx/modules/factory/contrib/go.opentelemetry.io/otel/v1`: +it starts both providers and shuts them down on the app's stop, within the +stop context. A shutdown error (an unreachable collector) is logged at +ERROR and does not fail the stop, so a job that succeeded still exits 0. + +Without the shutdown, a stop drops the spans still queued and the metrics +since the last interval (60s by default), and a short job can exit before +its first export. + +## Config + +| Key | Default | | +|---|---|---| +| `boost.factory.otel.trace.enabled` | `true` | starts the tracer provider | +| `boost.factory.otel.metric.enabled` | `true` | starts the meter provider | +| `boost.factory.otel.protocol` | `grpc` | `grpc` or `http` (`OTEL_EXPORTER_OTLP_PROTOCOL`) | +| `boost.factory.otel.endpoint` | `localhost:4317` | collector address (`OTEL_EXPORTER_OTLP_ENDPOINT`) | +| `boost.factory.otel.insecure` | `true` | plain-text connection to the collector | +| `boost.factory.otel.trace.ratio` | `1.0` | sampling ratio (`OTEL_TRACES_SAMPLER_ARG`) | +| `boost.factory.otel.export.interval` | `60s` | metric export interval (`OTEL_METRIC_EXPORT_INTERVAL`) | +| `boost.factory.otel.export.timeout` | `30s` | metric export timeout (`OTEL_METRIC_EXPORT_TIMEOUT`) | +| `boost.factory.otel.console.enabled` | `false` | also prints spans and metrics to stdout | + +Per-signal endpoint and protocol (`.trace.endpoint`, `.metric.protocol`, ...) +and the resource keys (`env`, `version`, `attributes`, `tls.cert`) are in +`config.go`. + +## Testing: `oteltest` + +`oteltest.NewCollector()` runs an OTLP/gRPC collector in the test process +and records the names of the spans and metrics it receives. Point the +factory at it before `boost.Start()`: + +```go +collector, err := oteltest.NewCollector() +require.NoError(t, err) +t.Cleanup(collector.Close) +t.Setenv("BOOST_FACTORY_OTEL_ENDPOINT", collector.Endpoint()) + +boost.Start() +// ... start the app, call it, stop it ... +require.Contains(t, collector.Spans(), "GET /stock/:sku") +``` diff --git a/factory/contrib/go.opentelemetry.io/otel/v1/metric.go b/factory/contrib/go.opentelemetry.io/otel/v1/metric.go index f2125fe4..a4888933 100644 --- a/factory/contrib/go.opentelemetry.io/otel/v1/metric.go +++ b/factory/contrib/go.opentelemetry.io/otel/v1/metric.go @@ -41,13 +41,18 @@ var metricOnce sync.Once // StartMetricProviderWithOptions starts the tracer provider with the given set of options. Calling // it multiple times will have no effect. If an error occours during tracer initialization, -// a Noop trace provider will be used instead. +// a Noop trace provider will be used instead. After Shutdown it only logs a warning. func StartMetricProviderWithOptions(ctx context.Context, options *Options, startOptions ...sdkmetric.Option) { if !IsMetricEnabled() { return } + if shutDown.Load() { + log.FromContext(ctx).Warn("opentelemetry was shut down; the meter provider does not start again") + return + } + metricOnce.Do(func() { logger := log.FromContext(ctx) @@ -94,6 +99,7 @@ func StartMetricProviderWithOptions(ctx context.Context, options *Options, start otel.SetMeterProvider(prov) MeterProvider = prov + meterProvider = prov err = runtime.Start(runtime.WithMinimumReadMemStatsInterval(5 * time.Second)) if err != nil { diff --git a/factory/contrib/go.opentelemetry.io/otel/v1/options.go b/factory/contrib/go.opentelemetry.io/otel/v1/options.go index 3ab40580..ded506ef 100644 --- a/factory/contrib/go.opentelemetry.io/otel/v1/options.go +++ b/factory/contrib/go.opentelemetry.io/otel/v1/options.go @@ -22,10 +22,10 @@ type Options struct { Console struct { Enabled bool } - Protocol string - Endpoint string - Insecure bool - Metric struct { + Protocol string + Endpoint string + Insecure bool + Metric struct { Endpoint string Protocol string } diff --git a/factory/contrib/go.opentelemetry.io/otel/v1/oteltest/collector.go b/factory/contrib/go.opentelemetry.io/otel/v1/oteltest/collector.go new file mode 100644 index 00000000..bb25896f --- /dev/null +++ b/factory/contrib/go.opentelemetry.io/otel/v1/oteltest/collector.go @@ -0,0 +1,105 @@ +// Package oteltest runs an in-process OTLP/gRPC collector, so a test can +// point boost's otel factory at it (BOOST_FACTORY_OTEL_ENDPOINT, set before +// boost.Start) and assert which spans and metrics were exported, with no +// collector running on the machine. +package oteltest + +import ( + "context" + "net" + "sync" + + colmetricpb "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/grpc" +) + +// Collector receives OTLP exports over gRPC on a loopback port and keeps the +// names of the spans and metrics it received. +type Collector struct { + server *grpc.Server + endpoint string + + mu sync.Mutex + spans []string + metrics []string +} + +// NewCollector starts a Collector on a free loopback port. Close stops it. +func NewCollector() (*Collector, error) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + c := &Collector{server: grpc.NewServer(), endpoint: lis.Addr().String()} + coltracepb.RegisterTraceServiceServer(c.server, traceService{c: c}) + colmetricpb.RegisterMetricsServiceServer(c.server, metricService{c: c}) + + go func() { + _ = c.server.Serve(lis) // intentional: Serve only returns once Close stops the server + }() + + return c, nil +} + +// Endpoint is the host:port the collector listens on, the value for +// BOOST_FACTORY_OTEL_ENDPOINT (the default protocol is grpc, insecure). +func (c *Collector) Endpoint() string { + return c.endpoint +} + +// Spans returns the names of the spans received so far, in arrival order. +func (c *Collector) Spans() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.spans...) +} + +// Metrics returns the names of the metrics received so far, in arrival order. +func (c *Collector) Metrics() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.metrics...) +} + +// Close stops the collector and its open connections. +func (c *Collector) Close() { + c.server.Stop() +} + +type traceService struct { + coltracepb.UnimplementedTraceServiceServer + c *Collector +} + +func (s traceService) Export(_ context.Context, req *coltracepb.ExportTraceServiceRequest) (*coltracepb.ExportTraceServiceResponse, error) { + s.c.mu.Lock() + defer s.c.mu.Unlock() + for _, rs := range req.GetResourceSpans() { + for _, ss := range rs.GetScopeSpans() { + for _, span := range ss.GetSpans() { + s.c.spans = append(s.c.spans, span.GetName()) + } + } + } + return &coltracepb.ExportTraceServiceResponse{}, nil +} + +type metricService struct { + colmetricpb.UnimplementedMetricsServiceServer + c *Collector +} + +func (s metricService) Export(_ context.Context, req *colmetricpb.ExportMetricsServiceRequest) (*colmetricpb.ExportMetricsServiceResponse, error) { + s.c.mu.Lock() + defer s.c.mu.Unlock() + for _, rm := range req.GetResourceMetrics() { + for _, sm := range rm.GetScopeMetrics() { + for _, m := range sm.GetMetrics() { + s.c.metrics = append(s.c.metrics, m.GetName()) + } + } + } + return &colmetricpb.ExportMetricsServiceResponse{}, nil +} diff --git a/factory/contrib/go.opentelemetry.io/otel/v1/provider_test.go b/factory/contrib/go.opentelemetry.io/otel/v1/provider_test.go index 80585798..4ec8d149 100644 --- a/factory/contrib/go.opentelemetry.io/otel/v1/provider_test.go +++ b/factory/contrib/go.opentelemetry.io/otel/v1/provider_test.go @@ -2,20 +2,34 @@ package otel_test import ( "context" + "sync" "testing" + "time" "github.com/stretchr/testify/require" "github.com/xgodev/boost" otelboost "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1" + "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1/oteltest" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" ) +// The providers start once per process and Shutdown is final, so the whole +// life of the providers -- read before Start, Start, record, Shutdown -- is +// one test. +// // Plugins read TracerProvider/MeterProvider while their component is built, -// which in an fx app happens before anything starts the providers. -func TestProvidersReadBeforeStartForwardToStartedProviders(t *testing.T) { +// which in an fx app happens before anything starts the providers. A +// batching processor and a periodic reader hold what was recorded until +// their next export, which Shutdown must not lose. +func TestProvidersForwardFromBeforeStartAndShutdownFlushes(t *testing.T) { + collector, err := oteltest.NewCollector() + require.NoError(t, err) + t.Cleanup(collector.Close) + t.Setenv("BOOST_FACTORY_OTEL_ENDPOINT", collector.Endpoint()) + boost.Start() ctx := context.Background() @@ -27,21 +41,23 @@ func TestProvidersReadBeforeStartForwardToStartedProviders(t *testing.T) { require.NoError(t, err) spans := tracetest.NewInMemoryExporter() - otelboost.StartTracerProviderWithOptions(ctx, opts, sdktrace.WithSyncer(spans)) + pending := keptSpans{tracetest.NewInMemoryExporter()} + otelboost.StartTracerProviderWithOptions(ctx, opts, + sdktrace.WithSyncer(spans), + sdktrace.WithBatcher(pending, sdktrace.WithBatchTimeout(time.Hour))) reader := sdkmetric.NewManualReader() - otelboost.StartMetricProviderWithOptions(ctx, opts, sdkmetric.WithReader(reader)) + periodic := &metricExporter{} + otelboost.StartMetricProviderWithOptions(ctx, opts, + sdkmetric.WithReader(reader), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(periodic, sdkmetric.WithInterval(time.Hour)))) _, span := tracer.Start(ctx, "boost.test.op") span.End() counter.Add(ctx, 1) t.Run("spans", func(t *testing.T) { - var got []string - for _, s := range spans.GetSpans() { - got = append(got, s.Name) - } - require.Contains(t, got, "boost.test.op") + require.Contains(t, spanNames(spans), "boost.test.op") }) t.Run("metrics", func(t *testing.T) { @@ -56,4 +72,96 @@ func TestProvidersReadBeforeStartForwardToStartedProviders(t *testing.T) { } require.Contains(t, got, "boost.test.calls") }) + + t.Run("the batch and the periodic reader still hold them", func(t *testing.T) { + require.Empty(t, spanNames(pending.InMemoryExporter)) + require.Empty(t, periodic.exported()) + }) + + require.NoError(t, otelboost.Shutdown(ctx)) + + t.Run("shutdown exports the pending spans", func(t *testing.T) { + require.Contains(t, spanNames(pending.InMemoryExporter), "boost.test.op") + }) + + t.Run("shutdown exports the pending metrics", func(t *testing.T) { + require.Contains(t, periodic.exported(), "boost.test.calls") + }) + + t.Run("shutdown flushes the OTLP exporters to the collector", func(t *testing.T) { + require.Contains(t, collector.Spans(), "boost.test.op") + require.Contains(t, collector.Metrics(), "boost.test.calls") + }) + + t.Run("nothing is exported after shutdown", func(t *testing.T) { + _, span := tracer.Start(ctx, "boost.test.after") + span.End() + require.NotContains(t, spanNames(spans), "boost.test.after", "the syncer would have exported it at End") + + _, fresh := otelboost.TracerProvider.Tracer("boost-test").Start(ctx, "boost.test.fresh") + require.False(t, fresh.IsRecording(), "a tracer taken after shutdown is a noop") + }) + + t.Run("start after shutdown does nothing", func(t *testing.T) { + again := tracetest.NewInMemoryExporter() + otelboost.StartTracerProviderWithOptions(ctx, opts, sdktrace.WithSyncer(again)) + + _, span := otelboost.TracerProvider.Tracer("boost-test").Start(ctx, "boost.test.again") + span.End() + require.False(t, span.IsRecording()) + require.Empty(t, again.GetSpans()) + }) + + t.Run("a second shutdown is a no-op", func(t *testing.T) { + require.NoError(t, otelboost.Shutdown(ctx)) + }) +} + +func spanNames(e *tracetest.InMemoryExporter) []string { + var names []string + for _, s := range e.GetSpans() { + names = append(names, s.Name) + } + return names +} + +// keptSpans keeps the spans after Shutdown; tracetest's exporter resets +// them there, which would hide what the shutdown flushed. +type keptSpans struct{ *tracetest.InMemoryExporter } + +func (keptSpans) Shutdown(context.Context) error { return nil } + +// metricExporter records the names of the metrics it was asked to export. +type metricExporter struct { + mu sync.Mutex + names []string +} + +func (e *metricExporter) Temporality(k sdkmetric.InstrumentKind) metricdata.Temporality { + return sdkmetric.DefaultTemporalitySelector(k) } + +func (e *metricExporter) Aggregation(k sdkmetric.InstrumentKind) sdkmetric.Aggregation { + return sdkmetric.DefaultAggregationSelector(k) +} + +func (e *metricExporter) Export(_ context.Context, rm *metricdata.ResourceMetrics) error { + e.mu.Lock() + defer e.mu.Unlock() + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + e.names = append(e.names, m.Name) + } + } + return nil +} + +func (e *metricExporter) exported() []string { + e.mu.Lock() + defer e.mu.Unlock() + return append([]string(nil), e.names...) +} + +func (e *metricExporter) ForceFlush(context.Context) error { return nil } + +func (e *metricExporter) Shutdown(context.Context) error { return nil } diff --git a/factory/contrib/go.opentelemetry.io/otel/v1/shutdown.go b/factory/contrib/go.opentelemetry.io/otel/v1/shutdown.go new file mode 100644 index 00000000..a5e82910 --- /dev/null +++ b/factory/contrib/go.opentelemetry.io/otel/v1/shutdown.go @@ -0,0 +1,59 @@ +package otel + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +var ( + // tracerProvider and meterProvider are the SDK providers Start built, nil + // when that signal is off or failed to start. + tracerProvider *sdktrace.TracerProvider + meterProvider *sdkmetric.MeterProvider + + shutDown atomic.Bool +) + +// Shutdown exports what the started tracer and meter providers still hold +// (the span batch, the metrics since the last export) and stops them, within +// ctx; the error joins both providers' errors. It ends OpenTelemetry for the +// process, as an SDK provider cannot be restarted: after it nothing records, +// a Start only logs a warning and a second Shutdown returns nil. A provider +// that never started is skipped. +func Shutdown(ctx context.Context) error { + if shutDown.Swap(true) { + return nil + } + + // Waits for a Start in progress, and keeps a later one from building a + // provider nothing would shut down. + tracerOnce.Do(func() {}) + metricOnce.Do(func() {}) + + // Both export at once, so an unreachable collector costs the stop one + // export timeout, not two. + var wg sync.WaitGroup + var traceErr, metricErr error + if tracerProvider != nil { + wg.Go(func() { + if err := tracerProvider.Shutdown(ctx); err != nil { + traceErr = fmt.Errorf("shutting down the opentelemetry tracer provider: %w", err) + } + }) + } + if meterProvider != nil { + wg.Go(func() { + if err := meterProvider.Shutdown(ctx); err != nil { + metricErr = fmt.Errorf("shutting down the opentelemetry meter provider: %w", err) + } + }) + } + wg.Wait() + return errors.Join(traceErr, metricErr) +} diff --git a/factory/contrib/go.opentelemetry.io/otel/v1/tracer.go b/factory/contrib/go.opentelemetry.io/otel/v1/tracer.go index 44e29427..cba68661 100644 --- a/factory/contrib/go.opentelemetry.io/otel/v1/tracer.go +++ b/factory/contrib/go.opentelemetry.io/otel/v1/tracer.go @@ -40,13 +40,18 @@ var tracerOnce sync.Once // StartTracerProviderWithOptions starts the tracer provider with the given set of options. Calling // it multiple times will have no effect. If an error occours during tracer initialization, -// a Noop trace provider will be used instead. +// a Noop trace provider will be used instead. After Shutdown it only logs a warning. func StartTracerProviderWithOptions(ctx context.Context, options *Options, startOptions ...sdktrace.TracerProviderOption) { if !IsTraceEnabled() { return } + if shutDown.Load() { + log.FromContext(ctx).Warn("opentelemetry was shut down; the tracer provider does not start again") + return + } + tracerOnce.Do(func() { logger := log.FromContext(ctx) @@ -86,6 +91,7 @@ func StartTracerProviderWithOptions(ctx context.Context, options *Options, start otel.SetTracerProvider(prov) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) TracerProvider = prov + tracerProvider = prov log.Infof("started opentelemetry tracer: %s", options.Service) }) diff --git a/fx/README.md b/fx/README.md index e90ad0a0..60dc4a0c 100644 --- a/fx/README.md +++ b/fx/README.md @@ -34,7 +34,7 @@ Each `fx/modules/factory/contrib/` mirrors `factory/contrib/` and wr | `elastic/go-elasticsearch/v8`, `v9` | `Module`, `BulkModule` | `*elasticsearch.Client`, `esutil.BulkIndexer` | — | | `go-resty/resty/v2` | `Module` | `*resty.Client` | — | | `go.mongodb.org/mongo-driver/v1`, `v2` | `Module` | `*mongo.Conn`, `*mongo.Database` | — | -| `go.opentelemetry.io/otel/v1` | `Module` | starts tracer and meter providers | — | +| `go.opentelemetry.io/otel/v1` | `Module` | starts tracer and meter providers, shuts them down on stop | — | | `gocloud.dev/pubsub/v0` | `TopicModule`, `SubscriptionModule` | `*pubsub.Topic`, `*pubsub.Subscription` | — | | `gocql/gocql/v1` | `Module` | `*gocql.Session` | — | | `godror/godror/v0` | `Module` | `*sql.DB` | — | diff --git a/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module.go b/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module.go index 6b3fb35b..80a2060f 100644 --- a/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module.go +++ b/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module.go @@ -6,6 +6,7 @@ import ( xotel "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1" contextfx "github.com/xgodev/boost/fx/modules/core/context" + "github.com/xgodev/boost/wrapper/log" "go.uber.org/fx" ) @@ -14,18 +15,33 @@ var once sync.Once // Module starts the OpenTelemetry tracer and meter providers. Include this // module in any application that uses OTel-instrumented clients (PubSub, gRPC, // MongoDB, etc.) so that providers are initialized before the clients are created. +// On stop it shuts both providers down within the stop context, exporting the +// spans and metrics they still hold; an export that fails is logged at ERROR +// and does not fail the stop. func Module() fx.Option { options := fx.Options() once.Do(func() { options = fx.Options( contextfx.Module(), - fx.Invoke(func(ctx context.Context) { + fx.Invoke(func(ctx context.Context, lc fx.Lifecycle) { xotel.StartTracerProvider(ctx) xotel.StartMeterProvider(ctx) + lc.Append(fx.StopHook(func(stopCtx context.Context) { + shutdown(stopCtx, xotel.Shutdown) + })) }), ) }) return options } + +// shutdown runs stop and logs its error instead of returning it: telemetry +// the collector did not take must not turn a clean exit (a job that +// succeeded) into exit code 1. +func shutdown(ctx context.Context, stop func(context.Context) error) { + if err := stop(ctx); err != nil { + log.FromContext(ctx).Errorf("opentelemetry: %v", err) + } +} diff --git a/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module_test.go b/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module_test.go index a9fa7e18..6862061a 100644 --- a/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module_test.go +++ b/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1/module_test.go @@ -1,15 +1,101 @@ package otel import ( + "context" + "errors" + "fmt" + "os" "testing" "github.com/stretchr/testify/require" "github.com/xgodev/boost" + otelboost "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1" + "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1/oteltest" + "github.com/xgodev/boost/wrapper/log" "go.uber.org/fx" + "go.uber.org/fx/fxtest" ) -func TestModuleGraphIsComplete(t *testing.T) { +var ( + // module holds Module() from its first call: the module is once-guarded + // and yields nothing after that. + module fx.Option + // collector is where the providers the module starts export to. + collector *oteltest.Collector +) + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + var err error + collector, err = oteltest.NewCollector() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + defer collector.Close() + if err := os.Setenv("BOOST_FACTORY_OTEL_ENDPOINT", collector.Endpoint()); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + boost.Start() + module = Module() + return m.Run() +} + +func TestModuleGraphIsComplete(t *testing.T) { + require.NoError(t, fx.ValidateApp(module)) +} + +// Stopping the app must export what the providers still hold -- the metric +// export interval (60s) is far longer than the test, so only the stop can +// have sent the counter -- and shut the providers down. +func TestStopExportsWhatIsPendingAndShutsTheProvidersDown(t *testing.T) { + ctx := context.Background() + app := fxtest.New(t, module) + app.RequireStart() + + tracer := otelboost.TracerProvider.Tracer("boost-test") + counter, err := otelboost.MeterProvider.Meter("boost-test").Int64Counter("boost.test.calls") + require.NoError(t, err) + + _, span := tracer.Start(ctx, "boost.test.before") + require.True(t, span.IsRecording(), "the module starts the providers") + span.End() + counter.Add(ctx, 1) + + app.RequireStop() + + require.Contains(t, collector.Spans(), "boost.test.before") + require.Contains(t, collector.Metrics(), "boost.test.calls") + + _, after := otelboost.TracerProvider.Tracer("boost-test").Start(ctx, "boost.test.after") + require.False(t, after.IsRecording(), "the stop shut the providers down: a tracer taken after it is a noop") +} + +// errorLogger keeps the messages logged at ERROR. +type errorLogger struct { + log.Noop + errors []string +} + +func (l *errorLogger) FromContext(context.Context) log.Logger { return l } + +func (l *errorLogger) Errorf(format string, args ...interface{}) { + l.errors = append(l.errors, fmt.Sprintf(format, args...)) +} + +func TestShutdownLogsTheErrorInsteadOfFailingTheStop(t *testing.T) { + previous := log.GetLogger() + t.Cleanup(func() { log.Set(previous) }) + logger := &errorLogger{} + log.Set(logger) + + shutdown(context.Background(), func(context.Context) error { return errors.New("collector unreachable") }) + shutdown(context.Background(), func(context.Context) error { return nil }) - require.NoError(t, fx.ValidateApp(Module())) + require.Equal(t, []string{"opentelemetry: collector unreachable"}, logger.errors) } diff --git a/go.mod b/go.mod index 6369f027..7f7087d5 100644 --- a/go.mod +++ b/go.mod @@ -123,6 +123,7 @@ require ( go.opentelemetry.io/otel/sdk v1.47.0 go.opentelemetry.io/otel/sdk/metric v1.47.0 go.opentelemetry.io/otel/trace v1.47.0 + go.opentelemetry.io/proto/otlp v1.11.1 go.uber.org/fx v1.24.0 go.uber.org/zap v1.28.0 go.yaml.in/yaml/v3 v3.0.5 @@ -372,7 +373,6 @@ require ( go.opentelemetry.io/collector/pdata/pprofile v0.163.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.46.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect - go.opentelemetry.io/proto/otlp v1.11.1 // indirect go.uber.org/atomic v1.12.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/mock v0.6.0 // indirect From 7d00ad4bee2c7dfd741db72c329dcdc8fa0145a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Tue, 20 Oct 2026 02:23:39 -0300 Subject: [PATCH 2/4] fix(builder,inject): start the OTel providers when a door or client uses otel (#256) An app generated by builder could list the otel plugin on a server, the opentelemetry middleware on a function or the otel plugin on a client, and still export nothing: no generated module started the OpenTelemetry providers, so every plugin reported to the noop global provider. The door of a server that lists otel (rest, mcp, the cloudevents adapter), the door of a function that runs the opentelemetry middleware and the module of a client built with the otel plugin (resty, redis, mongo, sql) now include otelfx.Module(), which starts the providers on boot and shuts them down on stop. The module is once-guarded, so an app with several such doors starts them once. The builder example builds its warehouse client with otel and tests that the span of the call reaches a collector with the trace context sent on; examples/stock tests that the worker exports the span of an event. --- builder/README.md | 9 +- builder/annotations.go | 4 + builder/examples/simple/cmd/api/main_test.go | 99 +++++++++++++++-- .../simple/cmd/stock-audit/main_test.go | 29 ++++- .../go-resty/resty/v2/warehouse_client.go | 12 +- .../examples/simple/stock/remote/remote.go | 5 +- builder/function_door.go | 7 ++ builder/generator.go | 17 +++ builder/otel_test.go | 105 ++++++++++++++++++ .../cloudevents_function_module.go.golden | 2 + .../golden/server/ops_module.go.golden | 2 + examples/stock/README.md | 1 + examples/stock/cmd/worker/main_test.go | 30 ++++- .../gen/builder/worker/function_module.go | 2 + inject/README.md | 5 + inject/client.go | 19 +++- inject/otel_test.go | 77 +++++++++++++ inject/template.go | 1 + inject/templates/module.tmpl | 3 + inject/testdata/client/gold_db.go.golden | 2 + inject/testdata/client/quote_client.go.golden | 2 + 21 files changed, 413 insertions(+), 20 deletions(-) create mode 100644 builder/otel_test.go create mode 100644 inject/otel_test.go diff --git a/builder/README.md b/builder/README.md index c62e52c0..cd43933a 100644 --- a/builder/README.md +++ b/builder/README.md @@ -307,6 +307,12 @@ package rest - A `@Provide` `[]Plugin` still builds a server (see [Servers](#servers)): that server gets no default set, and a `@Server` for it too fails. +- A door whose server lists `otel` (or whose `@Function` runs the + `opentelemetry` middleware) also includes + [`otelfx.Module()`](../fx/modules/factory/contrib/go.opentelemetry.io/otel/v1): + it starts the OpenTelemetry providers on boot and shuts them down on stop, + so the spans and metrics reach `boost.factory.otel.endpoint` instead of a + noop provider. The module runs once per app, however many doors include it. ### App names @@ -377,7 +383,8 @@ Factory clients too: `@Inject(index=0, name=quote, plugins=[otel])` on a godror or pgx) parameter makes inject build that client with those plugins and its own config under `boost.factory.resty.clients.quote` (see -[`inject`](../inject#factory-clients)), so the app writes no factory module. +[`inject`](../inject#factory-clients)), so the app writes no factory module; +a client with the `otel` plugin starts the OpenTelemetry providers too. builder owns the main of every app: a `cmd//main.go` without that header fails the generation instead of being overwritten. Other files under `cmd/` diff --git a/builder/annotations.go b/builder/annotations.go index 7becbd69..caf5a61b 100644 --- a/builder/annotations.go +++ b/builder/annotations.go @@ -74,6 +74,9 @@ type functionMiddleware struct { // Needs are the unnamed values the middleware is built with, provided // by the app. Needs []typeRef + // OTel is set for a middleware that reports to the OpenTelemetry + // providers: the function door starts them. + OTel bool } var ( @@ -115,6 +118,7 @@ var ( Module: inject.ImportData{Alias: "opentelemetryfx", Path: fxMiddlewareRoot + "/opentelemetry"}, Pkg: inject.ImportData{Alias: "opentelemetrymw", Path: middlewareRoot + "/go.opentelemetry.io/otel/v1"}, Type: "OpenTelemetry", + OTel: true, }, "prometheus": { Module: inject.ImportData{Alias: "prometheusfx", Path: fxMiddlewareRoot + "/prometheus"}, diff --git a/builder/examples/simple/cmd/api/main_test.go b/builder/examples/simple/cmd/api/main_test.go index c66a2b10..159343a2 100644 --- a/builder/examples/simple/cmd/api/main_test.go +++ b/builder/examples/simple/cmd/api/main_test.go @@ -2,12 +2,14 @@ package main import ( "context" + "fmt" "net" "net/http" "net/http/httptest" "os" "os/exec" "reflect" + "slices" "strconv" "strings" "testing" @@ -20,6 +22,8 @@ import ( "github.com/xgodev/boost" "github.com/xgodev/boost/builder/examples/simple/gen/builder/api" "github.com/xgodev/boost/builder/examples/simple/stock" + otelboost "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1" + "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1/oteltest" grpcserver "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" @@ -31,10 +35,30 @@ import ( "google.golang.org/grpc/test/bufconn" ) -// TestMain boots boost: the servers read their config. +// TestMain boots boost: the servers read their config. The warehouse client +// exports spans: they go to a collector of the process, +// which the processes the tests start inherit, unless a test points its own +// at another. func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + collector, err := oteltest.NewCollector() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + defer collector.Close() + if os.Getenv(otelEndpointEnv) == "" { + if err := os.Setenv(otelEndpointEnv, collector.Endpoint()); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + } + boost.Start() - os.Exit(m.Run()) + return m.Run() } func serve(srv *boostecho.Server, method, target, body string) *httptest.ResponseRecorder { @@ -148,21 +172,35 @@ const ( // warehouseHostEnv points the warehouse client builder generates for // stock/remote at a server (boost.factory.resty.clients.warehouse.host). warehouseHostEnv = "BOOST_FACTORY_RESTY_CLIENTS_WAREHOUSE_HOST" + // otelEndpointEnv is where the OpenTelemetry providers export + // (boost.factory.otel.endpoint). + otelEndpointEnv = "BOOST_FACTORY_OTEL_ENDPOINT" ) -// TestAPIImplementation boots app api in a new process per value of the -// implementation key: config is read once per process. The remote -// implementation asks a warehouse this test serves. -func TestAPIImplementation(t *testing.T) { - warehouse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// warehouse serves the stock of the remote implementation: it is out of +// sold-out only. Each request's traceparent header goes to traceparents. +func warehouse(t *testing.T, traceparents chan<- string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/items/") { http.NotFound(w, r) return } + if traceparents != nil { + traceparents <- r.Header.Get("traceparent") + } w.Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON) _, _ = w.Write([]byte(`{"available":` + strconv.FormatBool(r.URL.Path != "/items/sold-out") + `}`)) })) - t.Cleanup(warehouse.Close) + t.Cleanup(srv.Close) + return srv +} + +// TestAPIImplementation boots app api in a new process per value of the +// implementation key: config is read once per process. The remote +// implementation asks a warehouse this test serves. +func TestAPIImplementation(t *testing.T) { + warehouse := warehouse(t, nil) for _, value := range []string{"unlimited", "remote", "nope"} { t.Run(value, func(t *testing.T) { @@ -211,6 +249,51 @@ func TestAPIImplementationChild(t *testing.T) { } } +// TestAPITelemetry boots app api in a new process that exports to a +// collector of its own, with the remote implementation: the warehouse client +// is built with the otel plugin, so its generated module starts the +// providers and an order leaves the span of the call to the warehouse, which +// receives its trace context. +func TestAPITelemetry(t *testing.T) { + collector, err := oteltest.NewCollector() + require.NoError(t, err) + t.Cleanup(collector.Close) + traceparents := make(chan string, 1) + warehouse := warehouse(t, traceparents) + + cmd := exec.Command(os.Args[0], "-test.run=^TestAPITelemetryChild$", "-test.v") + cmd.Env = append(os.Environ(), implementationEnv+"=remote", warehouseHostEnv+"="+warehouse.URL, + otelEndpointEnv+"="+collector.Endpoint(), childEnv+"=1") + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", out) + assert.Contains(t, string(out), "--- PASS: TestAPITelemetryChild") + + spans := collector.Spans() + assert.True(t, slices.ContainsFunc(spans, func(name string) bool { return strings.Contains(name, http.MethodGet) }), + "the warehouse client exports the span of GET /items/{item}: %v", spans) + select { + case traceparent := <-traceparents: + assert.NotEmpty(t, traceparent, "the warehouse client sends the trace context") + default: + t.Fatal("the warehouse was not called") + } +} + +// TestAPITelemetryChild runs in the process TestAPITelemetry starts: it +// places an order and shuts the providers down, which exports the spans. +func TestAPITelemetryChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPITelemetry") + } + + var rest *boostecho.Server + app := fx.New(append(api.Modules(), fx.NopLogger, fx.Populate(&rest))...) + require.NoError(t, app.Err()) + + assert.Equal(t, http.StatusCreated, serve(rest, http.MethodPost, "/orders", `{"item":"book"}`).Code) + require.NoError(t, otelboost.Shutdown(context.Background())) +} + // soldOutEnv sets the items the memory stock is out of // (builder.examples.simple.stock.memory.soldOut, from its @Config Options). const soldOutEnv = "BUILDER_EXAMPLES_SIMPLE_STOCK_MEMORY_SOLD__OUT" diff --git a/builder/examples/simple/cmd/stock-audit/main_test.go b/builder/examples/simple/cmd/stock-audit/main_test.go index 480eb10c..c69c8bfa 100644 --- a/builder/examples/simple/cmd/stock-audit/main_test.go +++ b/builder/examples/simple/cmd/stock-audit/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "fmt" "net/http" "net/http/httptest" "os" @@ -19,13 +20,34 @@ import ( "github.com/xgodev/boost/bootstrap/job" stockaudit "github.com/xgodev/boost/builder/examples/simple/gen/builder/stock-audit" "github.com/xgodev/boost/builder/examples/simple/stock" + "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1/oteltest" "go.uber.org/fx" ) -// TestMain boots boost: the stock and the audit read their config. +// TestMain boots boost: the stock and the audit read their config. The +// warehouse client is built with the otel plugin, so every process the tests +// start exports to a collector of the tests: without one, the providers +// retry the export when the app stops. func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + collector, err := oteltest.NewCollector() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + defer collector.Close() + if os.Getenv(otelEndpointEnv) == "" { + if err := os.Setenv(otelEndpointEnv, collector.Endpoint()); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + } + boost.Start() - os.Exit(m.Run()) + return m.Run() } // TestAudit builds app stock-audit from the generated Modules() and runs the @@ -56,6 +78,9 @@ const ( // requestTimeoutEnv sets how long the warehouse client waits for an // answer (boost.factory.resty.clients.warehouse.requestTimeout). requestTimeoutEnv = "BOOST_FACTORY_RESTY_CLIENTS_WAREHOUSE_REQUEST__TIMEOUT" + // otelEndpointEnv is where the OpenTelemetry providers export + // (boost.factory.otel.endpoint). + otelEndpointEnv = "BOOST_FACTORY_OTEL_ENDPOINT" ) // TestMain_ExitsZeroWhenTheJobSucceeds runs the generated main of app diff --git a/builder/examples/simple/gen/client/github.com/go-resty/resty/v2/warehouse_client.go b/builder/examples/simple/gen/client/github.com/go-resty/resty/v2/warehouse_client.go index 3d8f7f5b..889bc0f3 100644 --- a/builder/examples/simple/gen/client/github.com/go-resty/resty/v2/warehouse_client.go +++ b/builder/examples/simple/gen/client/github.com/go-resty/resty/v2/warehouse_client.go @@ -10,8 +10,10 @@ import ( client "github.com/go-resty/resty/v2" factory "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2" + otelplugin "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/plugins/contrib/dubonzi/otelresty/v1" logplugin "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/plugins/local/wrapper/log" contextfx "github.com/xgodev/boost/fx/modules/core/context" + otelfx "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" ) // warehouseClientRoot is the config root of the warehouse client (*github.com/go-resty/resty/v2.Client); @@ -21,12 +23,13 @@ const warehouseClientRoot = "boost.factory.resty.clients.warehouse" func init() { factory.ConfigAdd(warehouseClientRoot) logplugin.ConfigAdd(warehouseClientRoot + ".plugins.log") + otelplugin.ConfigAdd(warehouseClientRoot + ".plugins.otel") } var warehouseClientOnce sync.Once // WarehouseClientModule provides the warehouse client (*github.com/go-resty/resty/v2.Client), built from the keys under -// boost.factory.resty.clients.warehouse with the plugins log. +// boost.factory.resty.clients.warehouse with the plugins log, otel. // It returns an empty option after the first call, so every consumer can // include it. func WarehouseClientModule() fx.Option { @@ -35,6 +38,7 @@ func WarehouseClientModule() fx.Option { warehouseClientOnce.Do(func() { options = fx.Module("github.com/go-resty/resty/v2.WarehouseClient", contextfx.Module(), + otelfx.Module(), fx.Provide(fx.Annotate(newWarehouseClient, fx.ResultTags(`name:"warehouse"`))), ) }) @@ -47,5 +51,9 @@ func newWarehouseClient(ctx context.Context) (*client.Client, error) { if err != nil { return nil, err } - return factory.NewClientWithConfigPath(ctx, warehouseClientRoot, log.Register) + otel, err := otelplugin.NewOtelrestyWithConfigPath(warehouseClientRoot + ".plugins.otel") + if err != nil { + return nil, err + } + return factory.NewClientWithConfigPath(ctx, warehouseClientRoot, log.Register, otel.Register) } diff --git a/builder/examples/simple/stock/remote/remote.go b/builder/examples/simple/stock/remote/remote.go index e0e42ee8..eab6a51c 100644 --- a/builder/examples/simple/stock/remote/remote.go +++ b/builder/examples/simple/stock/remote/remote.go @@ -22,9 +22,10 @@ type item struct { // New provides a stock service that asks the warehouse whether an item is // available (GET /items/{item}); builder builds its client with the log -// plugin. +// and otel plugins, and the module of the client starts the OpenTelemetry +// providers. // @Provide(index=0) -// @Inject(index=0, name=warehouse, plugins=[log]) +// @Inject(index=0, name=warehouse, plugins=[log, otel]) func New(warehouse *resty.Client) stock.Service { return &service{warehouse: warehouse} } diff --git a/builder/function_door.go b/builder/function_door.go index 959a7438..7bccc4ab 100644 --- a/builder/function_door.go +++ b/builder/function_door.go @@ -2,6 +2,7 @@ package builder import ( "fmt" + "slices" "strconv" "strings" @@ -36,6 +37,9 @@ func functionDoor(base, app string, fn Function) (doorData, error) { } handlerType := imps.add("bsfunction", functionPath) + ".Handler[" + result + "]" + if slices.ContainsFunc(fn.Middlewares, func(name string) bool { return functionMiddlewares[name].OTel }) { + d.Options = append(d.Options, otelModule(imps)) + } d.Options = append(d.Options, modules...) d.Options = append(d.Options, "fx.Provide(func("+strings.Join(params, ", ")+") "+handlerType+" {\nreturn "+call+"\n})", @@ -111,6 +115,9 @@ func adapterOptions(base string, imps *imports, result string, fn Function, name } // the adapter is built with the plugins its @Server lists if plugins := fn.Builtin[name]; len(plugins) > 0 { + if slices.Contains(plugins, otelPlugin) { + options = append(options, otelModule(imps)) + } options = append(options, builtinOption(imps, name, plugins, "")) } return options, nil diff --git a/builder/generator.go b/builder/generator.go index eab4a04e..b2904a31 100644 --- a/builder/generator.go +++ b/builder/generator.go @@ -7,6 +7,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "github.com/xgodev/boost/fx/modules/extra/multiserver" @@ -41,8 +42,21 @@ const ( publisherPath = "github.com/xgodev/boost/wrapper/publisher" wrapperConfigPath = "github.com/xgodev/boost/wrapper/config" errorsPath = "github.com/xgodev/boost/model/errors" + otelFxPath = "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" ) +// otelPlugin is the plugin, in every server type that has one, that reports +// to the OpenTelemetry providers. +const otelPlugin = "otel" + +// otelModule returns the fx module that starts the OpenTelemetry providers: +// a door built with an OTel plugin or middleware includes it, or every span +// and metric goes to the noop provider. It is once-guarded, so doors of the +// same app can all include it. +func otelModule(imps *imports) string { + return imps.add("otelfx", otelFxPath) + ".Module()" +} + // Options are the options of the inject generator of the same application, // whose modules the doors include, and the directory of the mains. type Options struct { @@ -351,6 +365,9 @@ func serverDoor(base string, srv Server) (doorData, error) { } invoke := "func(srv *" + serverPkg + ".Server" + joinPrefixed(params) + ") {\n" + strings.Join(body, "\n") + "\n}" + if slices.Contains(srv.Builtin, otelPlugin) { + d.Options = append(d.Options, otelModule(imps)) + } if isDefault(srv) { def := defaultServers[srv.Kind] diff --git a/builder/otel_test.go b/builder/otel_test.go new file mode 100644 index 00000000..0b9395a5 --- /dev/null +++ b/builder/otel_test.go @@ -0,0 +1,105 @@ +package builder + +import ( + "bytes" + "go/parser" + "go/token" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost/annotation" +) + +// otelModulePath is the fx module that starts the OpenTelemetry providers; +// without it every OTel plugin and middleware reports to a noop provider. +const otelModulePath = "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" + +// startsOpenTelemetry tells whether src includes the module of otelModulePath. +func startsOpenTelemetry(t *testing.T, name string, src []byte) bool { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), name, src, parser.ImportsOnly) + require.NoError(t, err, "%s is not valid Go", name) + for _, imp := range file.Imports { + p, err := strconv.Unquote(imp.Path.Value) + require.NoError(t, err) + if p != otelModulePath { + continue + } + alias := "otel" + if imp.Name != nil { + alias = imp.Name.Name + } + return bytes.Contains(src, []byte(alias+".Module()")) + } + return false +} + +// TestGenerator_RenderStartsOpenTelemetry checks that each door using an +// OTel plugin or middleware includes the module that starts the providers, +// and that a door that uses none does not. +func TestGenerator_RenderStartsOpenTelemetry(t *testing.T) { + testCases := []struct { + name string + entries []annotation.Entry + starts []string // the files that start the providers + plain []string // the files that do not + }{ + { + name: "rest server with the otel plugin", + entries: append(validEntries(), pkgDoc("server/rest", ann("Server", "type=rest, plugins=[recover, otel]"))), + starts: []string{"builder/api/rest_module.go"}, + plain: []string{"builder/api/admin_module.go", "builder/api/grpc_module.go", "builder/api/mcp_module.go"}, + }, + { + name: "mcp server with the otel plugin", + entries: append(validEntries(), pkgDoc("server/mcp", ann("Server", "type=mcp, plugins=[log, otel]"))), + starts: []string{"builder/api/mcp_module.go"}, + plain: []string{"builder/api/rest_module.go", "builder/api/admin_module.go", "builder/api/grpc_module.go"}, + }, + { + name: "cloudevents adapter with the otel plugin", + entries: append(validEntries(), + decl("events", "", "Handle", eventParams, eventResults, ann("Function", "adapter=cloudevents, app=events")), + pkgDoc("server/events", ann("Server", "type=cloudevents, app=events, plugins=[otel]"))), + starts: []string{"builder/events/function_module.go"}, + plain: []string{"builder/api/rest_module.go"}, + }, + { + name: "function with the opentelemetry middleware", + entries: append(validEntries(), + decl("events", "", "Handle", eventParams, eventResults, ann("Function", "adapter=nats, app=events, middlewares=[opentelemetry]"))), + starts: []string{"builder/events/function_module.go"}, + plain: []string{"builder/api/rest_module.go", "builder/worker/function_module.go"}, + }, + { + name: "nothing uses otel", + entries: append(validEntries(), + decl("events", "", "Handle", eventParams, eventResults, ann("Function", "adapter=nats, app=events, middlewares=[recovery]"))), + plain: []string{"builder/api/rest_module.go", "builder/api/admin_module.go", "builder/api/grpc_module.go", + "builder/api/mcp_module.go", "builder/worker/function_module.go", "builder/events/function_module.go"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + files, err := render(t, tc.entries) + require.NoError(t, err) + + for name, src := range files { + assertImportsUsed(t, name, src) + } + for _, name := range tc.starts { + src, ok := files[name] + require.True(t, ok, "files: %v", fileNames(files)) + assert.True(t, startsOpenTelemetry(t, name, src), "%s uses otel, so it starts the providers", name) + } + for _, name := range tc.plain { + src, ok := files[name] + require.True(t, ok, "files: %v", fileNames(files)) + assert.False(t, startsOpenTelemetry(t, name, src), "%s uses no otel", name) + } + }) + } +} diff --git a/builder/testdata/golden/server/cloudevents_function_module.go.golden b/builder/testdata/golden/server/cloudevents_function_module.go.golden index 6ad49ef9..b352d939 100644 --- a/builder/testdata/golden/server/cloudevents_function_module.go.golden +++ b/builder/testdata/golden/server/cloudevents_function_module.go.golden @@ -14,6 +14,7 @@ import ( ceotel "github.com/xgodev/boost/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0" functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" cloudeventsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http" + otelfx "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" ) var functionOnce sync.Once @@ -30,6 +31,7 @@ func FunctionModule() fx.Option { }), functionfx.Module[*src_cloudevents_c136a3fd.Event](nil), cloudeventsfx.Module[*src_cloudevents_c136a3fd.Event](), + otelfx.Module(), fx.Provide(func() []ceplugin.Plugin { return []ceplugin.Plugin{ ceotel.Register, diff --git a/builder/testdata/golden/server/ops_module.go.golden b/builder/testdata/golden/server/ops_module.go.golden index fa30b3fd..e77a56e6 100644 --- a/builder/testdata/golden/server/ops_module.go.golden +++ b/builder/testdata/golden/server/ops_module.go.golden @@ -15,6 +15,7 @@ import ( mcpotel "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/contrib/go.opentelemetry.io/otel/v1" mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" contextfx "github.com/xgodev/boost/fx/modules/core/context" + otelfx "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" ) func init() { @@ -30,6 +31,7 @@ func OpsModule() fx.Option { opsOnce.Do(func() { options = fx.Module("api.ops", + otelfx.Module(), contextfx.Module(), fx.Provide( fx.Annotate( diff --git a/examples/stock/README.md b/examples/stock/README.md index 00381eae..075572a4 100644 --- a/examples/stock/README.md +++ b/examples/stock/README.md @@ -115,6 +115,7 @@ Every switch is a config key; the env var is the key in upper case with `_`. | `BOOST_WRAPPER_PUBLISHER_DRIVER_NAME=nats` | announce `stock.reserved` on NATS (`BOOST_FACTORY_NATS_URL`) | | `WORKER_FUNCTION_ADAPTER=nats` | the worker reads `order.created` from NATS (`BOOST_BOOTSTRAP_FUNCTION_ADAPTER_NATS_SUBJECTS`) | | `BOOST_FACTORY_ECHO_PLUGINS_PROMETHEUS_ENABLED=false` | turns one plugin of a server off; the others stay | +| `BOOST_FACTORY_OTEL_ENDPOINT=collector:4317` | where the worker exports its traces and metrics (default `localhost:4317`): the `otel` plugin of its cloudevents `@Server` starts the OpenTelemetry providers | | `INTERNAL_APP_STOCKCHECK_JOB_STOCK_CHECKER_SKUS=tshirt,mug` | the skus the job checks | ## Where each resource is diff --git a/examples/stock/cmd/worker/main_test.go b/examples/stock/cmd/worker/main_test.go index bc2486af..c8bec2a1 100644 --- a/examples/stock/cmd/worker/main_test.go +++ b/examples/stock/cmd/worker/main_test.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "sync" + "syscall" "testing" "time" @@ -26,6 +27,7 @@ import ( "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver" "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" "github.com/xgodev/boost/extra/middleware" + "github.com/xgodev/boost/factory/contrib/go.opentelemetry.io/otel/v1/oteltest" functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" "github.com/xgodev/boost/model/errors" "go.uber.org/fx" @@ -187,7 +189,32 @@ func TestMain_ReservesOverHTTP(t *testing.T) { t.Run("with the otel plugin turned off", func(t *testing.T) { reservesOverHTTP(t, otelEnv+"=false") }) } -func reservesOverHTTP(t *testing.T, env ...string) { +// TestMain_ExportsTheSpanOfAnEvent runs the generated main of app worker +// exporting to a collector of the test: the @Server of the cloudevents +// adapter lists the otel plugin, so the generated door starts the +// OpenTelemetry providers and, on SIGTERM, they export the span of the event. +func TestMain_ExportsTheSpanOfAnEvent(t *testing.T) { + collector, err := oteltest.NewCollector() + require.NoError(t, err) + t.Cleanup(collector.Close) + + w := reservesOverHTTP(t, otelEndpointEnv+"="+collector.Endpoint()) + require.NoError(t, w.cmd.Process.Signal(syscall.SIGTERM)) + require.Eventually(t, func() bool { return len(collector.Spans()) > 0 }, 10*time.Second, 50*time.Millisecond, + "the worker exports the span of the event: %s", w.out) +} + +// otelEndpointEnv is where the OpenTelemetry providers export +// (boost.factory.otel.endpoint). +const otelEndpointEnv = "BOOST_FACTORY_OTEL_ENDPOINT" + +// runningWorker is the process reservesOverHTTP starts. +type runningWorker struct { + cmd *exec.Cmd + out *syncBuffer +} + +func reservesOverHTTP(t *testing.T, env ...string) runningWorker { lis, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) port := lis.Addr().(*net.TCPAddr).Port @@ -230,6 +257,7 @@ func reservesOverHTTP(t *testing.T, env ...string) { } } assert.Contains(t, out.String(), "listening on :"+strconv.Itoa(port)+"/", "the worker logs the port it listens on") + return runningWorker{cmd: cmd, out: out} } // syncBuffer is a bytes.Buffer the child process and the test share. diff --git a/examples/stock/gen/builder/worker/function_module.go b/examples/stock/gen/builder/worker/function_module.go index db80cfbd..36b7e100 100644 --- a/examples/stock/gen/builder/worker/function_module.go +++ b/examples/stock/gen/builder/worker/function_module.go @@ -22,6 +22,7 @@ import ( natsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1" loggerfx "github.com/xgodev/boost/fx/modules/bootstrap/function/middleware/logger" recoveryfx "github.com/xgodev/boost/fx/modules/bootstrap/function/middleware/recovery" + otelfx "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" errors "github.com/xgodev/boost/model/errors" config "github.com/xgodev/boost/wrapper/config" ) @@ -79,6 +80,7 @@ func functionAdapterModule() fx.Option { case "cloudevents": return fx.Options( cloudeventsfx.Module[[]*src_cloudevents_c136a3fd.Event](), + otelfx.Module(), fx.Provide(func() []ceplugin.Plugin { return []ceplugin.Plugin{ ceotel.Register, diff --git a/inject/README.md b/inject/README.md index e288a618..cfa43f60 100644 --- a/inject/README.md +++ b/inject/README.md @@ -297,6 +297,11 @@ func newEventsConn(ctx context.Context, lc fx.Lifecycle) (*nats.Conn, error) { Each plugin sits under the client (`boost.factory.resty.clients.quote.plugins.otel.enabled`, default `true`): the annotation chooses the plugins, config only turns one off. +- **OpenTelemetry.** The module of a client with the `otel` plugin also + includes + [`otelfx.Module()`](../fx/modules/factory/contrib/go.opentelemetry.io/otel/v1), + which starts the providers on boot and shuts them down on stop (once per + app): without it the plugin reports to a noop provider. - **Shared.** Consumers with the same type and name share one client. Each may list `plugins` only if the lists are equal; a consumer without `plugins` takes the client as the others declare it; no list anywhere is a diff --git a/inject/client.go b/inject/client.go index 302cad76..59e5c210 100644 --- a/inject/client.go +++ b/inject/client.go @@ -45,10 +45,17 @@ type ClientPlugin struct { New string // ...WithConfigPath constructor of the plugin Register string // method passed to the factory, "" for Register Value bool // the factory takes the value itself (a sql.Plugin), not a method + // OTel is set for a plugin that reports to the OpenTelemetry providers: + // the client module starts them, or the plugin reports to the noop + // provider. + OTel bool } const boostFactories = "github.com/xgodev/boost/factory/contrib/" +// otelFxPath is the fx module that starts the OpenTelemetry providers. +const otelFxPath = "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" + // clientFactories are the clients inject builds, by Path + "." + Type. var clientFactories = map[string]ClientFactory{} @@ -57,7 +64,7 @@ func init() { addClientFactory("github.com/go-resty/resty/v2", "Client", "resty", resty, "NewClientWithConfigPath", "boost.factory.resty", map[string]ClientPlugin{ "log": {Path: resty + "/plugins/local/wrapper/log", New: "NewLogWithConfigPath"}, "health": {Path: resty + "/plugins/local/extra/health", New: "NewHealthWithConfigPath"}, - "otel": {Path: resty + "/plugins/contrib/dubonzi/otelresty/v1", New: "NewOtelrestyWithConfigPath"}, + "otel": {Path: resty + "/plugins/contrib/dubonzi/otelresty/v1", New: "NewOtelrestyWithConfigPath", OTel: true}, "datadog": {Path: resty + "/plugins/contrib/datadog/dd-trace-go/v1", New: "NewDatadogWithConfigPath"}, "prometheus": {Path: resty + "/plugins/contrib/prometheus/client_golang/v1", New: "NewPrometheusWithConfigPath"}, "retry": {Path: resty + "/plugins/extra/retry", New: "NewRetryWithConfigPath"}, @@ -68,7 +75,7 @@ func init() { redisPlugins := func(health, others string) map[string]ClientPlugin { return map[string]ClientPlugin{ "health": {Path: redis + "/plugins/local/extra/health", New: health}, - "otel": {Path: redis + "/plugins/native/otel/v9", New: others}, + "otel": {Path: redis + "/plugins/native/otel/v9", New: others, OTel: true}, "datadog": {Path: redis + "/plugins/contrib/datadog/dd-trace-go/v1", New: others}, "prometheus": {Path: redis + "/plugins/contrib/prometheus/client_golang/v1", New: others}, } @@ -82,7 +89,7 @@ func init() { mongo := boostFactories + "go.mongodb.org/mongo-driver/" + version addClosingClientFactory(mongo, "Conn", "mongo", mongo, "NewConnWithConfigPath", "boost.factory.mongo", map[string]ClientPlugin{ "health": {Path: mongo + "/plugins/local/health", New: "NewHealthWithConfigPath"}, - "otel": {Path: mongo + "/plugins/contrib/go.opentelemetry.io/contrib/v0", New: "NewOtelMongoWithConfigPath"}, + "otel": {Path: mongo + "/plugins/contrib/go.opentelemetry.io/contrib/v0", New: "NewOtelMongoWithConfigPath", OTel: true}, "datadog": {Path: mongo + "/plugins/contrib/datadog/dd-trace-go/v1", New: "NewDatadogWithConfigPath"}, "prometheus": {Path: mongo + "/plugins/contrib/prometheus/client_golang/v1", New: "NewPrometheusWithConfigPath"}, }) @@ -133,7 +140,7 @@ func init() { }, Plugins: map[string]ClientPlugin{ "health": {Path: sqlPlugins + "/local/health", New: "NewHealthPluginWithConfigPath", Value: true}, - "otel": {Path: sqlPlugins + "/contrib/xsam/otelsql/v0", New: "NewOTelWithConfigPath", Value: true}, + "otel": {Path: sqlPlugins + "/contrib/xsam/otelsql/v0", New: "NewOTelWithConfigPath", Value: true, OTel: true}, "datadog": {Path: sqlPlugins + "/contrib/datadog/dd-trace-go/v1", New: "NewDatadogWithConfigPath", Value: true}, }} clientFactories[db.Path+"."+db.Type] = db @@ -594,6 +601,10 @@ func clientData(c Client) ClientData { } data.Plugins = append(data.Plugins, ClientPluginData{Name: name, Alias: alias, New: plugin.New, Register: register}) data.Imports = append(data.Imports, ImportData{Alias: alias, Path: plugin.Path}) + data.OTel = data.OTel || plugin.OTel + } + if data.OTel { + data.Imports = append(data.Imports, ImportData{Alias: "otelfx", Path: otelFxPath}) } return data } diff --git a/inject/otel_test.go b/inject/otel_test.go new file mode 100644 index 00000000..a93f28b4 --- /dev/null +++ b/inject/otel_test.go @@ -0,0 +1,77 @@ +package inject + +import ( + "bytes" + "context" + "go/parser" + "go/token" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost/annotation" +) + +// otelModulePath is the fx module that starts the OpenTelemetry providers; +// without it every OTel plugin reports to a noop provider. +const otelModulePath = "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" + +// startsOpenTelemetry tells whether src includes the module of otelModulePath. +func startsOpenTelemetry(t *testing.T, name string, src []byte) bool { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), name, src, parser.ImportsOnly) + require.NoError(t, err, "%s is not valid Go", name) + for _, imp := range file.Imports { + p, err := strconv.Unquote(imp.Path.Value) + require.NoError(t, err) + if p != otelModulePath { + continue + } + alias := "otel" + if imp.Name != nil { + alias = imp.Name.Name + } + return bytes.Contains(src, []byte(alias+".Module()")) + } + return false +} + +// TestGenerator_RenderClientStartsOpenTelemetry renders a client of each +// factory that has an otel plugin, with and without it: the module of a +// client built with the otel plugin starts the providers. +func TestGenerator_RenderClientStartsOpenTelemetry(t *testing.T) { + testCases := []struct { + name, dir, pkg, path, tp, file string + otel, plain string // the @Inject with and without the otel plugin + }{ + {name: "resty", dir: "quote", pkg: "resty", path: restyPath, tp: "*resty.Client", + otel: "index=0, name=quote, plugins=[log, otel]", plain: "index=0, name=quote, plugins=[log]", + file: "client/github.com/go-resty/resty/v2/quote_client.go"}, + {name: "redis", dir: "redis", pkg: "redis", path: redisPath, tp: "*redis.Client", + otel: "index=0, name=cache, plugins=[otel]", plain: "index=0, name=cache, plugins=[health]", + file: "client/github.com/redis/go-redis/v9/cache_client.go"}, + {name: "mongo", dir: "mongo", pkg: "mongo", path: mongoPath, tp: "*mongo.Conn", + otel: "index=0, name=stock, plugins=[otel]", plain: "index=0, name=stock, plugins=[health]", + file: "client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/stock_conn.go"}, + {name: "sql", dir: "sql", pkg: "sql", path: "database/sql", tp: "*sql.DB", + otel: "index=0, name=gold, drivers=[godror], plugins=[otel]", plain: "index=0, name=gold, drivers=[godror], plugins=[health]", + file: "client/database/sql/gold_db.go"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for raw, want := range map[string]bool{tc.otel: true, tc.plain: false} { + entry := at("client/"+tc.dir, "New", []string{tc.tp}, []string{"*Repository"}, provide("index=0"), inject(raw)) + entry.Imports = map[string]string{tc.pkg: tc.path} + + files, err := NewGenerator(newTestGraph(t, []annotation.Entry{entry}), testOptions("/mod")).Render(context.Background()) + require.NoError(t, err) + + src := files[tc.file] + require.NotNil(t, src, "the client is rendered at %s", tc.file) + assert.Equal(t, want, startsOpenTelemetry(t, tc.file, src), "@Inject(%s)", raw) + } + }) + } +} diff --git a/inject/template.go b/inject/template.go index af518ae0..0e620f3e 100644 --- a/inject/template.go +++ b/inject/template.go @@ -79,6 +79,7 @@ type ClientData struct { Plugins []ClientPluginData // in the declared order Names string // plugin names, comma separated Close bool // the module closes the client when the app stops + OTel bool // a plugin reports to the OpenTelemetry providers: the module starts them Imports []ImportData // client type, factory, context module and plugins } diff --git a/inject/templates/module.tmpl b/inject/templates/module.tmpl index faddbe4b..f545fb90 100644 --- a/inject/templates/module.tmpl +++ b/inject/templates/module.tmpl @@ -196,6 +196,9 @@ func {{.Ident}}Module() fx.Option { {{.Var}}Once.Do(func() { options = fx.Module("{{.Module}}", contextfx.Module(), +{{- if .OTel}} + otelfx.Module(), +{{- end}} fx.Provide({{.Target}}), ) }) diff --git a/inject/testdata/client/gold_db.go.golden b/inject/testdata/client/gold_db.go.golden index 36f996ae..b92d21f2 100644 --- a/inject/testdata/client/gold_db.go.golden +++ b/inject/testdata/client/gold_db.go.golden @@ -13,6 +13,7 @@ import ( otelplugin "github.com/xgodev/boost/factory/core/database/sql/plugins/contrib/xsam/otelsql/v0" healthplugin "github.com/xgodev/boost/factory/core/database/sql/plugins/local/health" contextfx "github.com/xgodev/boost/fx/modules/core/context" + otelfx "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" ) // goldDBRoot is the config root of the gold client (*database/sql.DB); @@ -37,6 +38,7 @@ func GoldDBModule() fx.Option { goldDBOnce.Do(func() { options = fx.Module("database/sql.GoldDB", contextfx.Module(), + otelfx.Module(), fx.Provide(fx.Annotate(newGoldDB, fx.ResultTags(`name:"gold"`))), ) }) diff --git a/inject/testdata/client/quote_client.go.golden b/inject/testdata/client/quote_client.go.golden index 789dd6c5..461bee20 100644 --- a/inject/testdata/client/quote_client.go.golden +++ b/inject/testdata/client/quote_client.go.golden @@ -13,6 +13,7 @@ import ( otelplugin "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/plugins/contrib/dubonzi/otelresty/v1" prometheusplugin "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/plugins/contrib/prometheus/client_golang/v1" contextfx "github.com/xgodev/boost/fx/modules/core/context" + otelfx "github.com/xgodev/boost/fx/modules/factory/contrib/go.opentelemetry.io/otel/v1" ) // quoteClientRoot is the config root of the quote client (*github.com/go-resty/resty/v2.Client); @@ -37,6 +38,7 @@ func QuoteClientModule() fx.Option { quoteClientOnce.Do(func() { options = fx.Module("github.com/go-resty/resty/v2.QuoteClient", contextfx.Module(), + otelfx.Module(), fx.Provide(fx.Annotate(newQuoteClient, fx.ResultTags(`name:"quote"`))), ) }) From c9f531a3c99828e0b6a99f33ecba2a0bca58614d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Tue, 20 Oct 2026 10:26:27 -0300 Subject: [PATCH 3/4] fix(bootstrap/function): the cloudevents http adapter stops on the run context (#262) The cloudevents_http command served on context.Background(), so its receiver never returned: on SIGTERM the OnStop of the function module waited the whole fx stop timeout (15s) and the app exited 1 with "Failed to stop cleanly: context deadline exceeded". It now runs RunContext(cmd.Context(), ...): when the app stops it takes no new request, shuts its server down once the events in hand are answered and returns, so the worker exits 0 at once. The handler runs with a context the stop does not cancel, so the event it holds finishes. A protocol option that fails now fails Run instead of a nil protocol. Run is deprecated in favour of RunContext. The generated main of examples/stock worker gets a test that sends SIGTERM after a reservation and requires exit 0 within two seconds. --- .../cloudevents/sdk-go/v2/core/http/cmd.go | 5 +- .../cloudevents/sdk-go/v2/core/http/run.go | 21 ++- .../sdk-go/v2/core/http/run_test.go | 161 ++++++++++++++++++ examples/stock/README.md | 7 +- examples/stock/cmd/worker/main_test.go | 41 ++++- fx/modules/bootstrap/function/README.md | 6 +- 6 files changed, 226 insertions(+), 15 deletions(-) diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/cmd.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/cmd.go index d0d5259d..e43b9fd9 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/cmd.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/cmd.go @@ -6,7 +6,8 @@ import ( "github.com/xgodev/boost/bootstrap/function" ) -// New returns CmdFunc for cloudevents command. +// New returns the CmdFunc of the cloudevents_http command: it serves the +// events posted to it until the run context is done. func New[T any](opts []client.Option, plugins ...Plugin) function.CmdFunc[T] { return func(fn function.Handler[T]) *co.Command { return &co.Command{ @@ -14,7 +15,7 @@ func New[T any](opts []client.Option, plugins ...Plugin) function.CmdFunc[T] { Short: "cloudevents_http", Long: "", RunE: func(cmd *co.Command, args []string) error { - return Run[T](fn, opts, plugins...) + return RunContext[T](cmd.Context(), fn, opts, plugins...) }, } } diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go index a8975760..7753714c 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go @@ -17,10 +17,19 @@ import ( // Plugin defines a function to process plugin. type Plugin func(context.Context, []cehttp.Option) []cehttp.Option -func Run[T any](fn function.Handler[T], opts []client.Option, plugins ...Plugin) (err error) { - - ctx := context.Background() +// Run serves fn with a context that is never cancelled, so it never returns +// on its own. +// +// Deprecated: use RunContext, which stops when its context is done. +func Run[T any](fn function.Handler[T], opts []client.Option, plugins ...Plugin) error { + return RunContext[T](context.Background(), fn, opts, plugins...) +} +// RunContext serves fn over HTTP on Port() and Path() until ctx is done: +// then it takes no new event, shuts the server down once the events in hand +// are answered, and returns. A handler runs with a context the stop does not +// cancel, so the event it holds finishes. +func RunContext[T any](ctx context.Context, fn function.Handler[T], opts []client.Option, plugins ...Plugin) error { logger := log.FromContext(ctx) httpOpts := []cehttp.Option{ce.WithPort(Port()), ce.WithPath(Path())} @@ -32,6 +41,7 @@ func Run[T any](fn function.Handler[T], opts []client.Option, plugins ...Plugin) p, err := ce.NewHTTP(httpOpts...) if err != nil { logger.Errorf("failed to create protocol: %s", err.Error()) + return err } c, err := ce.NewClient(p, opts...) @@ -42,7 +52,10 @@ func Run[T any](fn function.Handler[T], opts []client.Option, plugins ...Plugin) logger.Infof("listening on :%d%s", Port(), Path()) - return c.StartReceiver(ctx, Wrapper(fn)) + handle := Wrapper(fn) + return c.StartReceiver(ctx, func(ctx context.Context, event ce.Event) ce.Result { + return handle(context.WithoutCancel(ctx), event) + }) } func Wrapper[T any](fn function.Handler[T]) func(context.Context, ce.Event) ce.Result { diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run_test.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run_test.go index 29c9216d..f7d0c149 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run_test.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run_test.go @@ -1,12 +1,30 @@ package http import ( + "context" + "net" "net/http" + "os" + "strconv" + "strings" "testing" + "time" + ce "github.com/cloudevents/sdk-go/v2" + "github.com/cloudevents/sdk-go/v2/client" + cehttp "github.com/cloudevents/sdk-go/v2/protocol/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" "github.com/xgodev/boost/model/errors" ) +// TestMain boots boost: the adapter reads its port and path from config. +func TestMain(m *testing.M) { + boost.Start() + os.Exit(m.Run()) +} + type customForbidden struct{ msg string } func (c *customForbidden) Error() string { return c.msg } @@ -28,3 +46,146 @@ func TestErrorStatusCode_BuiltinNotFound(t *testing.T) { t.Fatal("builtin NotFound not mapped to 404") } } + +// withPort is a Plugin that makes the adapter listen on port. +func withPort(port int) Plugin { + return func(_ context.Context, opts []cehttp.Option) []cehttp.Option { + return append(opts, cehttp.WithPort(port)) + } +} + +// freePort returns a TCP port nothing listens on. +func freePort(t *testing.T) int { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := lis.Addr().(*net.TCPAddr).Port + require.NoError(t, lis.Close()) + return port +} + +// post sends one order.created CloudEvent to port until the adapter +// listens, and returns the status of its reply. +func post(port int) (int, error) { + client := &http.Client{Timeout: 10 * time.Second} + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + deadline := time.After(10 * time.Second) + for { + req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+strconv.Itoa(port)+"/", strings.NewReader(`{"sku":"tshirt"}`)) + if err != nil { + return 0, err + } + req.Header.Set("Ce-Id", "evt-1") + req.Header.Set("Ce-Source", "test") + req.Header.Set("Ce-Type", "order.created") + req.Header.Set("Ce-Specversion", "1.0") + req.Header.Set("Content-Type", "application/json") + res, err := client.Do(req) + if err == nil { + _ = res.Body.Close() + return res.StatusCode, nil + } + select { + case <-tick.C: + case <-deadline: + return 0, err + } + } +} + +// TestRunContext_LetsTheHandlerFinishAndReturns cancels the run context +// while the handler holds an event (#262): the handler's context is not +// cancelled by the stop, the sender gets the reply and RunContext returns +// nil, so the app stops at once instead of waiting the fx stop timeout. +func TestRunContext_LetsTheHandlerFinishAndReturns(t *testing.T) { + port := freePort(t) + held := make(chan context.Context, 1) + release := make(chan struct{}) + fn := func(ctx context.Context, _ ce.Event) ([]*ce.Event, error) { + held <- ctx + <-release + return nil, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ran := make(chan error, 1) + go func() { ran <- RunContext[[]*ce.Event](ctx, fn, nil, withPort(port)) }() + + type reply struct { + status int + err error + } + replied := make(chan reply, 1) + go func() { + status, err := post(port) + replied <- reply{status, err} + }() + + var handlerCtx context.Context + select { + case handlerCtx = <-held: + case err := <-ran: + t.Fatalf("RunContext returned (%v) before the event", err) + case r := <-replied: + t.Fatalf("the event was answered (%d, %v) without the handler", r.status, r.err) + } + + cancel() + assert.NoError(t, handlerCtx.Err(), "the stop cancelled the event in hand") + close(release) + + r := <-replied + require.NoError(t, r.err) + assert.Equal(t, http.StatusOK, r.status, "the sender gets the reply of the event in hand") + select { + case err := <-ran: + assert.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("RunContext did not return after the run context was cancelled") + } +} + +// TestRunContext_FailsOnABadProtocolOption returns the error of an option +// the protocol rejects, instead of logging it and failing on a nil protocol. +func TestRunContext_FailsOnABadProtocolOption(t *testing.T) { + fn := func(context.Context, ce.Event) ([]*ce.Event, error) { return nil, nil } + + err := RunContext[[]*ce.Event](context.Background(), fn, nil, withPort(-1)) + assert.ErrorContains(t, err, "invalid port: -1") + + err = Run[[]*ce.Event](fn, nil, withPort(-1)) + assert.ErrorContains(t, err, "invalid port: -1", "Run is RunContext with a context never cancelled") +} + +// TestRunContext_FailsOnABadClientOption returns the error of a client +// option that fails. +func TestRunContext_FailsOnABadClientOption(t *testing.T) { + fn := func(context.Context, ce.Event) ([]*ce.Event, error) { return nil, nil } + bad := func(any) error { return errors.New("bad client option") } + + err := RunContext[[]*ce.Event](context.Background(), fn, []client.Option{bad}, withPort(freePort(t))) + assert.ErrorContains(t, err, "bad client option") +} + +// TestNew_RunsOnTheCommandContext executes the cloudevents_http command with +// a context already done (#262): it runs on the command's context, so it +// returns instead of serving forever. +func TestNew_RunsOnTheCommandContext(t *testing.T) { + fn := func(context.Context, ce.Event) ([]*ce.Event, error) { return nil, nil } + cmd := New[[]*ce.Event](nil, withPort(freePort(t)))(fn) + assert.Equal(t, "cloudevents_http", cmd.Use) + cmd.SetArgs([]string{}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ran := make(chan error, 1) + go func() { ran <- cmd.ExecuteContext(ctx) }() + select { + case err := <-ran: + assert.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("the command did not return on a context already done") + } +} diff --git a/examples/stock/README.md b/examples/stock/README.md index 075572a4..5c5846ac 100644 --- a/examples/stock/README.md +++ b/examples/stock/README.md @@ -93,7 +93,8 @@ $ curl localhost:8081 -H 'Ce-Id: 1' -H 'Ce-Source: shop' -H 'Ce-Type: order.crea ``` Its log says `reserved one tshirt for event 1, 1 left`, and app `api` now -answers `{"sku":"tshirt","units":1}`. +answers `{"sku":"tshirt","units":1}`. Ctrl-C (SIGTERM) stops the worker at +once with exit 0, after it answers the events in hand. The job: @@ -153,6 +154,8 @@ go test ./... `gen/` and `cmd/*/main.go` are generated and committed; `gen_test.go` fails when they are stale. `api/proto/stock/v1` is regenerated with the `protoc` line of its `doc.go`. The tests run each app in process on the defaults and -on each switch above; the MongoDB and Oracle ones skip unless +on each switch above, and send SIGTERM to the generated main of `worker` +after it reserved over HTTP: it exits 0 within two seconds, with and without +its `otel` plugin; the MongoDB and Oracle ones skip unless `BOOST_FACTORY_MONGO_URI` or `BOOST_FACTORY_GODROR_CONNECTSTRING` points at a database. diff --git a/examples/stock/cmd/worker/main_test.go b/examples/stock/cmd/worker/main_test.go index c8bec2a1..11b404b4 100644 --- a/examples/stock/cmd/worker/main_test.go +++ b/examples/stock/cmd/worker/main_test.go @@ -199,9 +199,24 @@ func TestMain_ExportsTheSpanOfAnEvent(t *testing.T) { t.Cleanup(collector.Close) w := reservesOverHTTP(t, otelEndpointEnv+"="+collector.Endpoint()) - require.NoError(t, w.cmd.Process.Signal(syscall.SIGTERM)) - require.Eventually(t, func() bool { return len(collector.Spans()) > 0 }, 10*time.Second, 50*time.Millisecond, - "the worker exports the span of the event: %s", w.out) + w.stops(t) + assert.NotEmpty(t, collector.Spans(), "the worker exports the span of the event: %s", w.out) +} + +// TestMain_StopsOnSIGTERM sends SIGTERM to the generated main of app worker +// after it reserved an item (#262): the cloudevents http adapter shuts its +// server down and returns, so the worker exits 0 at once instead of waiting +// the fx stop timeout, with the plugins of its @Server and with config +// turning one off. The OpenTelemetry providers export to a collector of the +// test: with none, their shutdown waits one export timeout (#257). +func TestMain_StopsOnSIGTERM(t *testing.T) { + collector, err := oteltest.NewCollector() + require.NoError(t, err) + t.Cleanup(collector.Close) + export := otelEndpointEnv + "=" + collector.Endpoint() + + t.Run("with the otel plugin", func(t *testing.T) { reservesOverHTTP(t, export).stops(t) }) + t.Run("with the otel plugin turned off", func(t *testing.T) { reservesOverHTTP(t, export, otelEnv+"=false").stops(t) }) } // otelEndpointEnv is where the OpenTelemetry providers export @@ -210,8 +225,22 @@ const otelEndpointEnv = "BOOST_FACTORY_OTEL_ENDPOINT" // runningWorker is the process reservesOverHTTP starts. type runningWorker struct { - cmd *exec.Cmd - out *syncBuffer + cmd *exec.Cmd + out *syncBuffer + exited <-chan error +} + +// stops sends SIGTERM to the worker and requires it to exit 0 within two +// seconds: well before the fx stop timeout (15s). +func (w runningWorker) stops(t *testing.T) { + t.Helper() + require.NoError(t, w.cmd.Process.Signal(syscall.SIGTERM)) + select { + case err := <-w.exited: + require.NoError(t, err, "the worker did not exit 0: %s", w.out) + case <-time.After(2 * time.Second): + t.Fatalf("the worker did not stop within two seconds of SIGTERM: %s", w.out) + } } func reservesOverHTTP(t *testing.T, env ...string) runningWorker { @@ -257,7 +286,7 @@ func reservesOverHTTP(t *testing.T, env ...string) runningWorker { } } assert.Contains(t, out.String(), "listening on :"+strconv.Itoa(port)+"/", "the worker logs the port it listens on") - return runningWorker{cmd: cmd, out: out} + return runningWorker{cmd: cmd, out: out, exited: exited} } // syncBuffer is a bytes.Buffer the child process and the test share. diff --git a/fx/modules/bootstrap/function/README.md b/fx/modules/bootstrap/function/README.md index 70f7bd27..016886b5 100644 --- a/fx/modules/bootstrap/function/README.md +++ b/fx/modules/bootstrap/function/README.md @@ -69,7 +69,11 @@ The `nats`, `cloudevents_nats` and `gcp_pubsub` (v1 and v2) adapters run on that context (`Helper.Run(cmd.Context())`) and block until it is done: when it is cancelled they take no new message, let the handler finish the one it holds, with a context the stop does not cancel, and return, so SIGTERM stops -the app with exit code 0 well before the stop timeout. NATS drains its +the app with exit code 0 well before the stop timeout. The `cloudevents_http` +adapter does the same (`RunContext(cmd.Context(), ...)`): it takes no new +request, shuts its server down once the events in hand are answered and +returns. An option the HTTP protocol rejects (e.g. a bad port) fails `Run`. +`http.Run` is deprecated: it serves with a context that is never cancelled. NATS drains its subscriptions; Pub/Sub gives back a failed message instead of retrying it. A NATS subject it cannot subscribe to, or a Pub/Sub subscription it cannot receive on (`pubsub: could not receive on `, e.g. one that does not From 4753aac5178e4c5346651f6dbbc8e6fb99317ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Tue, 20 Oct 2026 10:41:58 -0300 Subject: [PATCH 4/4] release: 1.7.1 Bumps the version shown in README and site: generated apps start and shut down the OpenTelemetry providers, and the cloudevents http adapter stops on SIGTERM, since 1.7.0. --- README.md | 4 ++-- docs/site/index.html | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d84af827..a582f356 100644 --- a/README.md +++ b/README.md @@ -936,7 +936,7 @@ same or get better, never worse. require ( - github.com/xgodev/boost v1.1.1 -+ github.com/xgodev/boost v1.7.0 ++ github.com/xgodev/boost v1.7.1 ) ``` @@ -947,7 +947,7 @@ ok github.com/acme/shop/... 1 line changed in go.mod, **0 lines changed in your code**. -Releases: 2024-07 → `v1.1.0` → `v1.1.1` → **`v1.7.0`** +Releases: 2024-07 → `v1.1.0` → `v1.1.1` → **`v1.7.1`** ([all releases](https://github.com/xgodev/boost/releases)). ## There's a factory for that. diff --git a/docs/site/index.html b/docs/site/index.html index d918d962..a0238f6e 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -36,7 +36,7 @@ Integrations @@ -48,7 +48,7 @@
-
v1.7.0MITgo 1.18+github.com/xgodev/boost
+
v1.7.1MITgo 1.18+github.com/xgodev/boost

Enterprise Go services.
Built fast.

Boot, config, logs, 58 factories, middleware, health and code generation in one framework. You write the business logic.

@@ -988,14 +988,14 @@

A gate that only moves forward

 module github.com/acme/shop
 
  require (
--	github.com/xgodev/boost v1.1.1+	github.com/xgodev/boost v1.7.0 )
+-	github.com/xgodev/boost v1.1.1+	github.com/xgodev/boost v1.7.1 )
 
 $ go build ./... && go test ./...
 ok  github.com/acme/shop/...
1 line changed in go.mod0 lines changed in your code
- 2024-07v1.1.0v1.1.1v1.7.0 + 2024-07v1.1.0v1.1.1v1.7.1