Skip to content

Commit

Permalink
story(issue-342): appbuilder simplify otel middleware (#343)
Browse files Browse the repository at this point in the history
  • Loading branch information
Zaba505 authored Dec 15, 2024
1 parent fa2e9cf commit 2581854
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 222 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
33 changes: 33 additions & 0 deletions app/app_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
})
}
12 changes: 6 additions & 6 deletions appbuilder/appbuilder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,36 +19,36 @@ 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
}
})

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) {
Expand Down
106 changes: 37 additions & 69 deletions appbuilder/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading

0 comments on commit 2581854

Please sign in to comment.