diff --git a/README.md b/README.md index 830ab97..d03f91b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) [![Go Reference](https://pkg.go.dev/badge/github.com/z5labs/bedrock.svg)](https://pkg.go.dev/github.com/z5labs/bedrock) [![Go Report Card](https://goreportcard.com/badge/github.com/z5labs/bedrock)](https://goreportcard.com/report/github.com/z5labs/bedrock) -![Coverage](https://img.shields.io/badge/Coverage-98.6%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-98.2%25-brightgreen) [![build](https://github.com/z5labs/bedrock/actions/workflows/build.yaml/badge.svg)](https://github.com/z5labs/bedrock/actions/workflows/build.yaml) **bedrock provides a minimal, modular and composable foundation for diff --git a/app/app.go b/app/app.go index 8a5af0b..2f58fd0 100644 --- a/app/app.go +++ b/app/app.go @@ -60,6 +60,27 @@ func (f LifecycleHookFunc) Run(ctx context.Context) error { return f(ctx) } +// ComposeLifecycleHooks combines multiple [LifecycleHook]s into a single hook. +// Each hook is called sequentially and each hook is called irregardless if a +// previous hook returned an error or not. Any and all errors are then returned +// after all hooks have been ran. +func ComposeLifecycleHooks(hooks ...LifecycleHook) LifecycleHook { + return LifecycleHookFunc(func(ctx context.Context) error { + errs := make([]error, 0, len(hooks)) + for _, hook := range hooks { + err := hook.Run(ctx) + if err == nil { + continue + } + errs = append(errs, err) + } + if len(errs) == 0 { + return nil + } + return errors.Join(errs...) + }) +} + // Lifecycle type Lifecycle struct { // PostRun is always executed regardless if the underlying [bedrock.App] diff --git a/app/app_example_test.go b/app/app_example_test.go index 57eb4d9..d5ed314 100644 --- a/app/app_example_test.go +++ b/app/app_example_test.go @@ -104,3 +104,36 @@ func ExampleWithLifecycleHooks_unrecoveredPanic() { // Output: ran post run hook } + +func ExampleComposeLifecycleHooks() { + var app bedrock.App = runFunc(func(ctx context.Context) error { + return nil + }) + + app = WithLifecycleHooks(app, Lifecycle{ + PostRun: ComposeLifecycleHooks( + LifecycleHookFunc(func(ctx context.Context) error { + fmt.Println("one") + return nil + }), + LifecycleHookFunc(func(ctx context.Context) error { + fmt.Println("two") + return nil + }), + LifecycleHookFunc(func(ctx context.Context) error { + fmt.Println("three") + return nil + }), + ), + }) + + err := app.Run(context.Background()) + if err != nil { + fmt.Println(err) + return + } + + // Output: one + // two + // three +} diff --git a/app/app_test.go b/app/app_test.go index 733236b..d36aef3 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -165,3 +165,57 @@ func TestWithLifecycleHooks(t *testing.T) { }) }) } + +func TestComposeLifecycleHooks(t *testing.T) { + t.Run("will return an error", func(t *testing.T) { + t.Run("if a single lifecycle hook failed", func(t *testing.T) { + errHookFailed := errors.New("failed to run hook") + + hook := ComposeLifecycleHooks( + LifecycleHookFunc(func(ctx context.Context) error { + return nil + }), + LifecycleHookFunc(func(ctx context.Context) error { + return errHookFailed + }), + LifecycleHookFunc(func(ctx context.Context) error { + return nil + }), + ) + + err := hook.Run(context.Background()) + if !assert.ErrorIs(t, err, errHookFailed) { + return + } + }) + + t.Run("if multiple lifecycle hooks failed", func(t *testing.T) { + errHookFailedOne := errors.New("failed to run hook: one") + errHookFailedTwo := errors.New("failed to run hook: two") + errHookFailedThree := errors.New("failed to run hook: three") + + hook := ComposeLifecycleHooks( + LifecycleHookFunc(func(ctx context.Context) error { + return errHookFailedOne + }), + LifecycleHookFunc(func(ctx context.Context) error { + return errHookFailedTwo + }), + LifecycleHookFunc(func(ctx context.Context) error { + return errHookFailedThree + }), + ) + + err := hook.Run(context.Background()) + if !assert.ErrorIs(t, err, errHookFailedOne) { + return + } + if !assert.ErrorIs(t, err, errHookFailedTwo) { + return + } + if !assert.ErrorIs(t, err, errHookFailedThree) { + return + } + }) + }) +} diff --git a/appbuilder/appbuilder_test.go b/appbuilder/appbuilder_test.go index 4e2ca53..019ebc4 100644 --- a/appbuilder/appbuilder_test.go +++ b/appbuilder/appbuilder_test.go @@ -19,11 +19,11 @@ func TestRecover(t *testing.T) { t.Run("will return an error", func(t *testing.T) { t.Run("if the underlying App returns an error", func(t *testing.T) { buildErr := errors.New("failed to build") - builder := Recover(bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { + builder := Recover(bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) { return nil, buildErr })) - _, err := builder.Build(context.Background(), config{}) + _, err := builder.Build(context.Background(), struct{}{}) if !assert.Equal(t, buildErr, err) { return } @@ -31,24 +31,24 @@ func TestRecover(t *testing.T) { t.Run("if the underlying App panics with an error value", func(t *testing.T) { buildErr := errors.New("failed to build") - builder := Recover(bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { + builder := Recover(bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) { panic(buildErr) return nil, nil })) - _, err := builder.Build(context.Background(), config{}) + _, err := builder.Build(context.Background(), struct{}{}) if !assert.ErrorIs(t, err, buildErr) { return } }) t.Run("if the underlying App panics with a non-error value", func(t *testing.T) { - builder := Recover(bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { + builder := Recover(bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) { panic("hello world") return nil, nil })) - _, err := builder.Build(context.Background(), config{}) + _, err := builder.Build(context.Background(), struct{}{}) var perr bedrock.PanicError if !assert.ErrorAs(t, err, &perr) { diff --git a/appbuilder/otel.go b/appbuilder/otel.go index 9cbcb7d..dfdc481 100644 --- a/appbuilder/otel.go +++ b/appbuilder/otel.go @@ -9,88 +9,56 @@ import ( "context" "github.com/z5labs/bedrock" - + "github.com/z5labs/bedrock/app" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/log" "go.opentelemetry.io/otel/log/global" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" ) -// TextMapPropagatorInitializer -type TextMapPropagatorInitializer interface { - InitTextMapPropogator(context.Context) (propagation.TextMapPropagator, error) +// OTelInitializer represents anything which can initialize the OTel SDK. +type OTelInitializer interface { + InitializeOTel(context.Context) error } -// TracerProviderInitializer -type TracerProviderInitializer interface { - InitTracerProvider(context.Context) (trace.TracerProvider, error) -} +// OTel is a [bedrock.AppBuilder] middleware which initializes the OTel SDK. +// It also ensures that the OTel SDK is properly shutdown when the built [bedrock.App] +// stops running. +func OTel[T OTelInitializer](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[T] { + return bedrock.AppBuilderFunc[T](func(ctx context.Context, cfg T) (bedrock.App, error) { + err := cfg.InitializeOTel(ctx) + if err != nil { + return nil, err + } -// MeterProviderInitializer -type MeterProviderInitializer interface { - InitMeterProvider(context.Context) (metric.MeterProvider, error) -} + base, err := builder.Build(ctx, cfg) + if err != nil { + return nil, err + } -// LoggerProviderInitializer -type LoggerProviderInitializer interface { - InitLoggerProvider(context.Context) (log.LoggerProvider, error) + base = app.WithLifecycleHooks(base, app.Lifecycle{ + PostRun: app.ComposeLifecycleHooks( + tryShutdown(otel.GetTracerProvider()), + tryShutdown(otel.GetMeterProvider()), + tryShutdown(global.GetLoggerProvider()), + ), + }) + return base, nil + }) } -// OTelInitializer -type OTelInitializer interface { - TextMapPropagatorInitializer - TracerProviderInitializer - MeterProviderInitializer - LoggerProviderInitializer +type shutdowner interface { + Shutdown(context.Context) error } -// OTel -func OTel[T OTelInitializer](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[T] { - return bedrock.AppBuilderFunc[T](func(ctx context.Context, cfg T) (bedrock.App, error) { - fs := []func(context.Context) error{ - func(ctx context.Context) error { - tmp, err := cfg.InitTextMapPropogator(ctx) - if err != nil || tmp == nil { - return err - } - otel.SetTextMapPropagator(tmp) - return nil - }, - func(ctx context.Context) error { - tp, err := cfg.InitTracerProvider(ctx) - if err != nil || tp == nil { - return err - } - otel.SetTracerProvider(tp) - return nil - }, - func(ctx context.Context) error { - mp, err := cfg.InitMeterProvider(ctx) - if err != nil || mp == nil { - return err - } - otel.SetMeterProvider(mp) - return nil - }, - func(ctx context.Context) error { - lp, err := cfg.InitLoggerProvider(ctx) - if err != nil || lp == nil { - return err - } - global.SetLoggerProvider(lp) - return nil - }, +func tryShutdown(v any) app.LifecycleHookFunc { + return func(ctx context.Context) error { + if v == nil { + return nil } - for _, f := range fs { - err := f(ctx) - if err != nil { - return nil, err - } + s, ok := v.(shutdowner) + if !ok { + return nil } - - return builder.Build(ctx, cfg) - }) + return s.Shutdown(ctx) + } } diff --git a/appbuilder/otel_test.go b/appbuilder/otel_test.go index 53912c9..29b85f9 100644 --- a/appbuilder/otel_test.go +++ b/appbuilder/otel_test.go @@ -10,208 +10,195 @@ import ( "errors" "testing" - "github.com/z5labs/bedrock" - "github.com/stretchr/testify/assert" - "go.opentelemetry.io/otel/log" + "github.com/z5labs/bedrock" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/log/global" lognoop "go.opentelemetry.io/otel/log/noop" - "go.opentelemetry.io/otel/metric" metricnoop "go.opentelemetry.io/otel/metric/noop" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/trace" tracenoop "go.opentelemetry.io/otel/trace/noop" ) -type config struct { - initTextMapPropogator func(context.Context) (propagation.TextMapPropagator, error) - initTracerProvider func(context.Context) (trace.TracerProvider, error) - initMeterProvider func(context.Context) (metric.MeterProvider, error) - initLoggerProvider func(context.Context) (log.LoggerProvider, error) +type failToInitOTel struct{} + +var failedToInitOTelErr = errors.New("failed to init otel") + +func (failToInitOTel) InitializeOTel(ctx context.Context) error { + return failedToInitOTelErr } -func (c config) InitTextMapPropogator(ctx context.Context) (propagation.TextMapPropagator, error) { - if c.initTextMapPropogator == nil { - return nil, nil - } - return c.initTextMapPropogator(ctx) +type noopInitOTel struct{} + +func (noopInitOTel) InitializeOTel(ctx context.Context) error { + return nil } -func (c config) InitTracerProvider(ctx context.Context) (trace.TracerProvider, error) { - if c.initTracerProvider == nil { - return nil, nil - } - return c.initTracerProvider(ctx) +type appFunc func(context.Context) error + +func (f appFunc) Run(ctx context.Context) error { + return f(ctx) +} + +type tracerProvider struct { + tracenoop.TracerProvider + shutdown func(context.Context) error } -func (c config) InitMeterProvider(ctx context.Context) (metric.MeterProvider, error) { - if c.initMeterProvider == nil { - return nil, nil +func newTracerProvider(shutdown func(context.Context) error) tracerProvider { + return tracerProvider{ + shutdown: shutdown, } - return c.initMeterProvider(ctx) } -func (c config) InitLoggerProvider(ctx context.Context) (log.LoggerProvider, error) { - if c.initLoggerProvider == nil { - return nil, nil +func (tp tracerProvider) Shutdown(ctx context.Context) error { + return tp.shutdown(ctx) +} + +type tracerProviderInitOTel struct{} + +var errTracerProviderFailedShutdown = errors.New("failed to shutdown tracer provider") + +func (tracerProviderInitOTel) InitializeOTel(ctx context.Context) error { + otel.SetTracerProvider(newTracerProvider(func(ctx context.Context) error { + return errTracerProviderFailedShutdown + })) + return nil +} + +type meterProvider struct { + metricnoop.MeterProvider + shutdown func(context.Context) error +} + +func newMeterProvider(shutdown func(context.Context) error) meterProvider { + return meterProvider{ + shutdown: shutdown, } - return c.initLoggerProvider(ctx) } -func TestOTel(t *testing.T) { - t.Run("will return an error", func(t *testing.T) { - t.Run("if the base bedrock.AppBuilder fails to run", func(t *testing.T) { - baseErr := errors.New("failed to run") - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, baseErr - }) - - app := OTel(base) - _, err := app.Build(context.Background(), config{}) - if !assert.ErrorIs(t, err, baseErr) { - return - } - }) +func (mp meterProvider) Shutdown(ctx context.Context) error { + return mp.shutdown(ctx) +} - t.Run("if propagation.TextMapPropagator fails to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) +type meterProviderInitOTel struct{} - initErr := errors.New("failed to init") - app := OTel(base) +var errMeterProviderFailedShutdown = errors.New("failed to shutdown meter provider") - _, err := app.Build(context.Background(), config{ - initTextMapPropogator: func(ctx context.Context) (propagation.TextMapPropagator, error) { - return nil, initErr - }, - }) - if !assert.ErrorIs(t, err, initErr) { - return - } - }) +func (meterProviderInitOTel) InitializeOTel(ctx context.Context) error { + otel.SetMeterProvider(newMeterProvider(func(ctx context.Context) error { + return errMeterProviderFailedShutdown + })) + return nil +} - t.Run("if trace.TracerProvider fails to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) +type loggerProvider struct { + lognoop.LoggerProvider + shutdown func(context.Context) error +} - initErr := errors.New("failed to init") - app := OTel(base) +func newLoggerProvider(shutdown func(context.Context) error) loggerProvider { + return loggerProvider{ + shutdown: shutdown, + } +} - _, err := app.Build(context.Background(), config{ - initTracerProvider: func(ctx context.Context) (trace.TracerProvider, error) { - return nil, initErr - }, - }) - if !assert.ErrorIs(t, err, initErr) { - return - } - }) +func (mp loggerProvider) Shutdown(ctx context.Context) error { + return mp.shutdown(ctx) +} - t.Run("if metric.MeterProvider fails to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) +type loggerProviderInitOTel struct{} + +var errLoggerProviderFailedShutdown = errors.New("failed to shutdown logger provider") - initErr := errors.New("failed to init") - app := OTel(base) +func (loggerProviderInitOTel) InitializeOTel(ctx context.Context) error { + global.SetLoggerProvider(newLoggerProvider(func(ctx context.Context) error { + return errLoggerProviderFailedShutdown + })) + return nil +} + +func TestOTel(t *testing.T) { + t.Run("bedrock.AppBuilder will return an error", func(t *testing.T) { + t.Run("if InitializeOTel fails", func(t *testing.T) { + b := OTel(bedrock.AppBuilderFunc[failToInitOTel](func(ctx context.Context, cfg failToInitOTel) (bedrock.App, error) { + return nil, nil + })) - _, err := app.Build(context.Background(), config{ - initMeterProvider: func(ctx context.Context) (metric.MeterProvider, error) { - return nil, initErr - }, - }) - if !assert.ErrorIs(t, err, initErr) { + _, err := b.Build(context.Background(), failToInitOTel{}) + if !assert.ErrorIs(t, err, failedToInitOTelErr) { return } }) - t.Run("if log.LoggerProvider fails to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) - - initErr := errors.New("failed to init") - app := OTel(base) + t.Run("if the given bedrock.AppBuilder fails", func(t *testing.T) { + buildErr := errors.New("failed to build") + b := OTel(bedrock.AppBuilderFunc[noopInitOTel](func(ctx context.Context, cfg noopInitOTel) (bedrock.App, error) { + return nil, buildErr + })) - _, err := app.Build(context.Background(), config{ - initLoggerProvider: func(ctx context.Context) (log.LoggerProvider, error) { - return nil, initErr - }, - }) - if !assert.ErrorIs(t, err, initErr) { + _, err := b.Build(context.Background(), noopInitOTel{}) + if !assert.ErrorIs(t, err, buildErr) { return } }) }) - t.Run("will not return an error", func(t *testing.T) { - t.Run("if propagation.TextMapPropagator succeeds to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) - - app := OTel(base) + t.Run("the built bedrock.App will return an error", func(t *testing.T) { + t.Run("if it fails to shutdown the tracer provider", func(t *testing.T) { + b := OTel(bedrock.AppBuilderFunc[tracerProviderInitOTel](func(ctx context.Context, cfg tracerProviderInitOTel) (bedrock.App, error) { + a := appFunc(func(ctx context.Context) error { + return nil + }) + return a, nil + })) - _, err := app.Build(context.Background(), config{ - initTextMapPropogator: func(ctx context.Context) (propagation.TextMapPropagator, error) { - return propagation.TraceContext{}, nil - }, - }) + app, err := b.Build(context.Background(), tracerProviderInitOTel{}) if !assert.Nil(t, err) { return } - }) - - t.Run("if trace.TracerProvider succeeds to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) - - app := OTel(base) - _, err := app.Build(context.Background(), config{ - initTracerProvider: func(ctx context.Context) (trace.TracerProvider, error) { - return tracenoop.NewTracerProvider(), nil - }, - }) - if !assert.Nil(t, err) { + err = app.Run(context.Background()) + if !assert.ErrorIs(t, err, errTracerProviderFailedShutdown) { return } }) - t.Run("if metric.MeterProvider succeeds to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) - - app := OTel(base) + t.Run("if it fails to shutdown the meter provider", func(t *testing.T) { + b := OTel(bedrock.AppBuilderFunc[meterProviderInitOTel](func(ctx context.Context, cfg meterProviderInitOTel) (bedrock.App, error) { + a := appFunc(func(ctx context.Context) error { + return nil + }) + return a, nil + })) - _, err := app.Build(context.Background(), config{ - initMeterProvider: func(ctx context.Context) (metric.MeterProvider, error) { - return metricnoop.NewMeterProvider(), nil - }, - }) + app, err := b.Build(context.Background(), meterProviderInitOTel{}) if !assert.Nil(t, err) { return } - }) - t.Run("if log.LoggerProvider succeeds to initialize", func(t *testing.T) { - base := bedrock.AppBuilderFunc[config](func(ctx context.Context, cfg config) (bedrock.App, error) { - return nil, nil - }) + err = app.Run(context.Background()) + if !assert.ErrorIs(t, err, errMeterProviderFailedShutdown) { + return + } + }) - app := OTel(base) + t.Run("if it fails to shutdown the logger provider", func(t *testing.T) { + b := OTel(bedrock.AppBuilderFunc[loggerProviderInitOTel](func(ctx context.Context, cfg loggerProviderInitOTel) (bedrock.App, error) { + a := appFunc(func(ctx context.Context) error { + return nil + }) + return a, nil + })) - _, err := app.Build(context.Background(), config{ - initLoggerProvider: func(ctx context.Context) (log.LoggerProvider, error) { - return lognoop.NewLoggerProvider(), nil - }, - }) + app, err := b.Build(context.Background(), loggerProviderInitOTel{}) if !assert.Nil(t, err) { return } + + err = app.Run(context.Background()) + if !assert.ErrorIs(t, err, errMeterProviderFailedShutdown) { + return + } }) }) }