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/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/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/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
diff --git a/examples/stock/README.md b/examples/stock/README.md index 00381eae..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: @@ -115,6 +116,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 @@ -152,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 bc2486af..11b404b4 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,61 @@ 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()) + 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 +// (boost.factory.otel.endpoint). +const otelEndpointEnv = "BOOST_FACTORY_OTEL_ENDPOINT" + +// runningWorker is the process reservesOverHTTP starts. +type runningWorker struct { + 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 { lis, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) port := lis.Addr().(*net.TCPAddr).Port @@ -230,6 +286,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, exited: exited} } // 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/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/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 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 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"`))), ) })