Repository: uber-go/fx
Pull request #733 (2021-05-04 01:42:11)

Add WithLogger option to supply a fxevent.Logger
This is a follow up to #717. This change adds a `WithLogger` option that
accepts a constructor that returns an `fxevent.Logger`. Unlike
`fx.Printer`, this constructor can have dependencies on other types
provided to the Fx graph. If supplied, we'll use this logger to log
events rather than our default behavior.

To make this work, we do the following:

- If `WithLogger` was not supplied, we use the old behavior of logging
  to stderr or the user-supplied `fx.Printer`.
- If `WithLogger` was supplied, we buffer log messages in-memory until
  we can build the user-supplied logger. If we successfully build the
  logger, we flush the buffered messages to it, and log to it from that
  point onwards. If we were unable to build the logger, we flush the
  buffered messages to stderr, or the user-supplied `fx.Printer` and
  fail application startup.

Further, to match our old behavior more accurately, this change includes
a `ConsoleLogger` implementation of `fxevent.Logger` that behaves almost
exactly like our logger before #717. This will make it easy to roll this
feature out without affecting users negatively, and also give us the
option of selecting between readable and non-readable log outputs based
on runtime environment (production vs development, for example).


Ref GO-77


codecov[bot]: # [Codecov](https://codecov.io/gh/uber-go/fx/pull/733?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) Report
> Merging [#733](https://codecov.io/gh/uber-go/fx/pull/733?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) (8d69e9e) into [master](https://codecov.io/gh/uber-go/fx/commit/4adc2bf73f2cdcd423bf22725985177670a91d4d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) (4adc2bf) will **decrease** coverage by `1.79%`.
> The diff coverage is `88.09%`.

[![Impacted file tree graph](https://codecov.io/gh/uber-go/fx/pull/733/graphs/tree.svg?width=650&height=150&src=pr&token=PYY9PSfaC6&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go)](https://codecov.io/gh/uber-go/fx/pull/733?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go)

```diff
@@            Coverage Diff             @@
##           master     #733      +/-   ##
==========================================
- Coverage   97.66%   95.86%   -1.80%     
==========================================
  Files          19       19              
  Lines         513      532      +19     
==========================================
+ Hits          501      510       +9     
- Misses         10       19       +9     
- Partials        2        3       +1     
```


| [Impacted Files](https://codecov.io/gh/uber-go/fx/pull/733?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) | Coverage Δ | |
|---|---|---|
| [internal/lifecycle/lifecycle.go](https://codecov.io/gh/uber-go/fx/pull/733/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-aW50ZXJuYWwvbGlmZWN5Y2xlL2xpZmVjeWNsZS5nbw==) | `100.00% <ø> (ø)` | |
| [app.go](https://codecov.io/gh/uber-go/fx/pull/733/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-YXBwLmdv) | `92.62% <87.17%> (-2.88%)` | :arrow_down: |
| [fxtest/app.go](https://codecov.io/gh/uber-go/fx/pull/733/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-Znh0ZXN0L2FwcC5nbw==) | `100.00% <100.00%> (ø)` | |
| [fxtest/printer.go](https://codecov.io/gh/uber-go/fx/pull/733/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-Znh0ZXN0L3ByaW50ZXIuZ28=) | `100.00% <100.00%> (ø)` | |
| [printer\_writer.go](https://codecov.io/gh/uber-go/fx/pull/733/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-cHJpbnRlcl93cml0ZXIuZ28=) | `0.00% <0.00%> (-75.00%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/uber-go/fx/pull/733?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/uber-go/fx/pull/733?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go). Last update [4adc2bf...8d69e9e](https://codecov.io/gh/uber-go/fx/pull/733?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go).


Patch:
diff --git a/app.go b/app.go
index 4f30e89..8275e3e 100644
--- a/app.go
+++ b/app.go
@@ -261,20 +261,40 @@ func (t stopTimeoutOption) String() string {
 	return fmt.Sprintf("fx.StopTimeout(%v)", time.Duration(t))
 }
 
-// withLogger will stay private until we export fxlog.Logger once
-// we finalize the API.
-func withLogger(l fxevent.Logger) Option {
-	return withLoggerOption{l}
+// WithLogger specifies how Fx should build an fxevent.Logger to log its events
+// to. The argument must be a constructor with one of the following return
+// types.
+//
+//   fxevent.Logger
+//   (fxevent.Logger, error)
+//
+// For example,
+//
+//   WithLogger(func(logger *zap.Logger) fxevent.Logger {
+//     return &fxevent.ZapLogger{Log: logger}
+//   })
+//
+func WithLogger(constructor interface{}) Option {
+	return withLoggerOption{
+		constructor: constructor,
+		Stack:       fxreflect.CallerStack(1, 0),
+	}
 }
 
-type withLoggerOption struct{ l fxevent.Logger }
+type withLoggerOption struct {
+	constructor interface{}
+	Stack       fxreflect.Stack
+}
 
 func (l withLoggerOption) apply(app *App) {
-	app.log = l.l
+	app.logConstructor = &provide{
+		Target: l.constructor,
+		Stack:  l.Stack,
+	}
 }
 
 func (l withLoggerOption) String() string {
-	return fmt.Sprintf("fx.withLogger(%v)", l.l)
+	return fmt.Sprintf("fx.WithLogger(%s)", fxreflect.FuncName(l.constructor))
 }
 
 // Printer is the interface required by Fx's logging backend. It's implemented
@@ -287,9 +307,7 @@ type Printer interface {
 }
 
 // Logger redirects the application's log output to the provided printer.
-//
-// Note, this will be deprecated with the next release and you will need to use
-// withLogger instead.
+// Deprecated: use WithLogger instead.
 func Logger(p Printer) Option {
 	return loggerOption{p}
 }
@@ -297,7 +315,7 @@ func Logger(p Printer) Option {
 type loggerOption struct{ p Printer }
 
 func (l loggerOption) apply(app *App) {
-	np := writeSyncerFromPrinter(l.p)
+	np := writerFromPrinter(l.p)
 	app.log = fxlog.DefaultLogger(np) // assuming np is thread-safe.
 }
 
@@ -307,9 +325,7 @@ func (l loggerOption) String() string {
 
 // NopLogger disables the application's log output. Note that this makes some
 // failures difficult to debug, since no errors are printed to console.
-//
-// Note, when withLogger is public we will make the change here as well.
-var NopLogger = withLogger(fxevent.NopLogger)
+var NopLogger = WithLogger(func() fxevent.Logger { return fxevent.NopLogger })
 
 // An App is a modular application built around dependency injection. Most
 // users will only need to use the New constructor and the all-in-one Run
@@ -346,17 +362,22 @@ var NopLogger = withLogger(fxevent.NopLogger)
 // execute one at a time, in reverse order, and must all complete within a
 // configurable deadline (again, 15 seconds by default).
 type App struct {
-	err          error
-	container    *dig.Container
-	lifecycle    *lifecycleWrapper
-	provides     []provide
-	invokes      []invoke
-	log          fxevent.Logger
+	err       error
+	container *dig.Container
+	lifecycle *lifecycleWrapper
+	// Constructors and its dependencies.
+	provides []provide
+	invokes  []invoke
+	// Used to setup logging within fx.
+	log            fxevent.Logger
+	logConstructor *provide // set only if fx.WithLogger was used
+	// Timeouts used
 	startTimeout time.Duration
 	stopTimeout  time.Duration
-	errorHooks   []ErrorHandler
-	validate     bool
-
+	// Decides how we react to errors when building the graph.
+	errorHooks []ErrorHandler
+	validate   bool
+	// Used to signal shutdowns.
 	donesMu sync.RWMutex
 	dones   []chan os.Signal
 }
@@ -445,6 +466,24 @@ func ValidateApp(opts ...Option) error {
 	return app.Err()
 }
 
+// Builds and connects the custom logger, returning an error if it failed.
+func (app *App) constructCustomLogger(buffer *logBuffer) error {
+	p := app.logConstructor
+
+	if err := app.container.Provide(p.Target); err != nil {
+		return fmt.Errorf("fx.WithLogger(%v) from:\n%+vFailed: %v",
+			fxreflect.FuncName(p.Target), p.Stack, err)
+	}
+
+	// TODO: Use dig.FillProvideInfo to inspect the provided constructor
+	// and fail the application if its signature didn't match.
+
+	return app.container.Invoke(func(log fxevent.Logger) {
+		app.log = log
+		buffer.Connect(log)
+	})
+}
+
 // New creates and initializes an App, immediately executing any functions
 // registered via Invoke options. See the documentation of the App struct for
 // details on the application's initialization, startup, and shutdown logic.
@@ -452,6 +491,18 @@ func New(opts ...Option) *App {
 	logger := fxlog.DefaultLogger(os.Stderr)
 
 	app := &App{
+		// We start with a logger that writes to stderr. One of the
+		// following three things can change this:
+		//
+		// - fx.Logger was provided to change the output stream
+		// - fx.WithLogger was provided to change the logger
+		//   implementation
+		// - Both, fx.Logger and fx.WithLogger were provided
+		//
+		// The first two cases are straightforward: we use what the
+		// user gave us. For the last case, however, we need to fall
+		// back to what was provided to fx.Logger if fx.WithLogger
+		// fails.
 		log:          logger,
 		startTimeout: DefaultTimeout,
 		stopTimeout:  DefaultTimeout,
@@ -460,7 +511,37 @@ func New(opts ...Option) *App {
 	for _, opt := range opts {
 		opt.apply(app)
 	}
-	app.lifecycle = &lifecycleWrapper{lifecycle.New(app.log)}
+
+	// There are a few levels of wrapping on the lifecycle here. To quickly
+	// cover them:
+	//
+	// - lifecycleWrapper ensures that we don't unintentionally expose the
+	//   Start and Stop methods of the internal lifecycle.Lifecycle type
+	// - lifecycleWrapper also adapts the internal lifecycle.Hook type into
+	//   the public fx.Hook type.
+	// - appLogger ensures that the lifecycle always logs events to the
+	//   "current" logger associated with the fx.App.
+	app.lifecycle = &lifecycleWrapper{
+		lifecycle.New(appLogger{app}),
+	}
+
+	var (
+		bufferLogger *logBuffer // nil if WithLogger was not used
+
+		// Logger we fall back to if the custom logger fails to build.
+		// This will be a DefaultLogger that writes to stderr if the
+		// user didn't use fx.Logger, and a DefaultLogger that writes
+		// to their output stream if they did.
+		fallbackLogger fxevent.Logger
+	)
+	if app.logConstructor != nil {
+		// Since user supplied a custom logger, use a buffered logger
+		// to hold all messages until user supplied logger is
+		// instantiated. Then we flush those messages after fully
+		// constructing the custom logger.
+		bufferLogger = new(logBuffer)
+		fallbackLogger, app.log = app.log, bufferLogger
+	}
 
 	app.container = dig.New(
 		dig.DeferAcyclicVerification(),
@@ -481,7 +562,29 @@ func New(opts ...Option) *App {
 
 	if app.err != nil {
 		app.log.LogEvent(&fxevent.ProvideError{Err: app.err})
+		// Don't return yet. If a custom logger was being used,
+		// we're still buffering messages. We'll want to flush them to
+		// the logger.
+	}
+
+	// If WithLogger and Printer are both provided, WithLogger takes
+	// precedence.
+	if app.logConstructor != nil {
+		// If we failed to build the provided logger, flush the buffer
+		// to the fallback logger instead.
+		if err := app.constructCustomLogger(bufferLogger); err != nil {
+			app.err = multierr.Append(app.err, err)
+			app.log = fallbackLogger
+			bufferLogger.Connect(fallbackLogger)
+			app.log.LogEvent(&fxevent.CustomLoggerError{Err: err})
+			return app
+		}
+		app.log.LogEvent(&fxevent.CustomLogger{Function: app.logConstructor})
+	}
 
+	// This error might have come from the provide looop above. We've
+	// already flushed to the custom logger, so we can return.
+	if app.err != nil {
 		return app
 	}
 
@@ -498,6 +601,7 @@ func New(opts ...Option) *App {
 		}
 		errorHandlerList(app.errorHooks).HandleError(err)
 	}
+
 	return app
 }
 
@@ -728,7 +832,7 @@ func (app *App) executeInvokes() error {
 			app.log.LogEvent(&fxevent.InvokeError{
 				Function:   fn,
 				Err:        err,
-				Stacktrace: i.Stack.String(),
+				Stacktrace: fmt.Sprintf("%+v", i.Stack), // format stack trace as multi-line
 			})
 
 			return err
@@ -791,3 +895,13 @@ func withTimeout(ctx context.Context, f func(context.Context) error) error {
 		return err
 	}
 }
+
+// appLogger logs events to the given Fx app's "current" logger.
+//
+// Use this with lifecycle, for example, to ensure that events always go to the
+// correct logger.
+type appLogger struct{ app *App }
+
+func (l appLogger) LogEvent(ev fxevent.Event) {
+	l.app.log.LogEvent(ev)
+}
diff --git a/fxevent/console.go b/fxevent/console.go
new file mode 100644
index 0000000..a722882
--- /dev/null
+++ b/fxevent/console.go
@@ -0,0 +1,82 @@
+// Copyright (c) 2021 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+package fxevent
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	"go.uber.org/fx/internal/fxreflect"
+)
+
+// ConsoleLogger is an Fx event logger that attempts to write human-readable
+// mesasges to the console.
+//
+// Use this during development.
+type ConsoleLogger struct {
+	W io.Writer
+}
+
+var _ Logger = (*ConsoleLogger)(nil)
+
+func (l *ConsoleLogger) logf(msg string, args ...interface{}) {
+	fmt.Fprintf(l.W, "[Fx] "+msg+"\n", args...)
+}
+
+// LogEvent logs the given event to the provided Zap logger.
+func (l *ConsoleLogger) LogEvent(event Event) {
+	switch e := event.(type) {
+	case *LifecycleHookStart:
+		l.logf("START\t\t%s", e.CallerName)
+	case *LifecycleHookStop:
+		l.logf("STOP\t\t%s", e.CallerName)
+	case *ProvideError:
+		l.logf("Error after options were applied: %v", e.Err)
+	case *Supply:
+		l.logf("SUPPLY\t%v", e.TypeName)
+	case *Provide:
+		for _, rtype := range e.OutputTypeNames {
+			l.logf("PROVIDE\t%v <= %v", rtype, fxreflect.FuncName(e.Constructor))
+		}
+	case *Invoke:
+		l.logf("INVOKE\t\t%s", fxreflect.FuncName(e.Function))
+	case *InvokeError:
+		l.logf("fx.Invoke(%v) called from:\n%+vFailed: %v",
+			fxreflect.FuncName(e.Function), e.Stacktrace, e.Err)
+	case *StartError:
+		l.logf("ERROR\t\tFailed to start: %v", e.Err)
+	case *StopSignal:
+		l.logf("%v", strings.ToUpper(e.Signal.String()))
+	case *StopError:
+		l.logf("ERROR\t\tFailed to stop cleanly: %v", e.Err)
+	case *RollbackError:
+		l.logf("ERROR\t\tCouldn't roll back cleanly: %v", e.Err)
+	case *Rollback:
+		l.logf("ERROR\t\tStart failed, rolling back: %v", e.StartErr)
+	case *Running:
+		l.logf("RUNNING")
+	case *CustomLoggerError:
+		l.logf("ERROR\t\tFailed to construct custom logger: %v", e.Err)
+	case *CustomLogger:
+		l.logf("LOGGER\tSetting up custom logger from %v", fxreflect.FuncName(e.Function))
+	}
+}
diff --git a/fxevent/event.go b/fxevent/event.go
index 8f17d8c..f36d4a6 100644
--- a/fxevent/event.go
+++ b/fxevent/event.go
@@ -43,6 +43,8 @@ func (*StopError) event()          {}
 func (*Rollback) event()           {}
 func (*RollbackError) event()      {}
 func (*Running) event()            {}
+func (*CustomLoggerError) event()  {}
+func (*CustomLogger) event()       {}
 
 // LifecycleHookStart is emitted whenever an OnStart hook is executed
 type LifecycleHookStart struct {
@@ -105,3 +107,11 @@ type RollbackError struct{ Err error }
 
 // Running is emitted whenever an application is started successfully.
 type Running struct{}
+
+// CustomLoggerError is emitted whenever a custom logger fails to construct.
+type CustomLoggerError struct{ Err error }
+
+// CustomLogger is emitted whenever a custom logger is set.
+type CustomLogger struct {
+	Function interface{}
+}
diff --git a/fxevent/zap.go b/fxevent/zap.go
index 3283122..a7dc04e 100644
--- a/fxevent/zap.go
+++ b/fxevent/zap.go
@@ -42,7 +42,8 @@ func (l *ZapLogger) LogEvent(event Event) {
 	case *LifecycleHookStop:
 		l.Logger.Info("stopping", zap.String("caller", e.CallerName))
 	case *ProvideError:
-		l.Logger.Error("error encountered while applying options", zap.Error(e.Err))
+		l.Logger.Error("error encountered while applying options",
+			zap.Error(e.Err))
 	case *Supply:
 		l.Logger.Info("supplying", zap.String("type", e.TypeName))
 	case *Provide:
@@ -53,7 +54,8 @@ func (l *ZapLogger) LogEvent(event Event) {
 			)
 		}
 	case *Invoke:
-		l.Logger.Info("invoke", zap.String("function", fxreflect.FuncName(e.Function)))
+		l.Logger.Info("invoke",
+			zap.String("function", fxreflect.FuncName(e.Function)))
 	case *InvokeError:
 		l.Logger.Error("fx.Invoke failed",
 			zap.Error(e.Err),
@@ -62,7 +64,8 @@ func (l *ZapLogger) LogEvent(event Event) {
 	case *StartError:
 		l.Logger.Error("failed to start", zap.Error(e.Err))
 	case *StopSignal:
-		l.Logger.Info("received signal", zap.String("signal", strings.ToUpper(e.Signal.String())))
+		l.Logger.Info("received signal",
+			zap.String("signal", strings.ToUpper(e.Signal.String())))
 	case *StopError:
 		l.Logger.Error("failed to stop cleanly", zap.Error(e.Err))
 	case *RollbackError:
@@ -71,5 +74,10 @@ func (l *ZapLogger) LogEvent(event Event) {
 		l.Logger.Error("startup failed, rolling back", zap.Error(e.StartErr))
 	case *Running:
 		l.Logger.Info("running")
+	case *CustomLoggerError:
+		l.Logger.Error("error constructing logger", zap.Error(e.Err))
+	case *CustomLogger:
+		l.Logger.Info("installing custom fxevent.Logger",
+			zap.String("function", fxreflect.FuncName(e.Function)))
 	}
 }
diff --git a/internal/fxlog/spy.go b/internal/fxlog/spy.go
index 4c9c8e0..84acb8f 100644
--- a/internal/fxlog/spy.go
+++ b/internal/fxlog/spy.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2020 Uber Technologies, Inc.
+// Copyright (c) 2020-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -26,8 +26,8 @@ import (
 	"go.uber.org/fx/fxevent"
 )
 
-// Spy is an Fx event logger that captures logged statements. It may be used in
-// tests of Fx logs.
+// Spy is an Fx event logger that captures emitted events and/or logged
+// statements. It may be used in tests of Fx logs.
 type Spy struct {
 	events []fxevent.Event
 }
@@ -55,7 +55,7 @@ func (s *Spy) EventTypes() []string {
 	return types
 }
 
-// Reset clears all messages from the Spy.
+// Reset clears all messages and events from the Spy.
 func (s *Spy) Reset() {
 	s.events = s.events[:0]
 }
diff --git a/internal/fxlog/fxlog.go b/log.go
similarity index 64%
rename from internal/fxlog/fxlog.go
rename to log.go
index c6070d5..bf7660e 100644
--- a/internal/fxlog/fxlog.go
+++ b/log.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2020 Uber Technologies, Inc.
+// Copyright (c) 2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -18,22 +18,33 @@
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 // THE SOFTWARE.
 
-package fxlog
+package fx
 
 import (
 	"go.uber.org/fx/fxevent"
-	"go.uber.org/zap"
-	"go.uber.org/zap/zapcore"
 )
 
-// DefaultLogger constructs a Logger out of io.Writer.
-func DefaultLogger(ws zapcore.WriteSyncer) fxevent.Logger {
-	zcore := zapcore.NewCore(
-		zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
-		ws,
-		zap.NewAtomicLevel(),
-	)
-	log := zap.New(zcore)
+// logBuffer will buffer all messages until a logger has been
+// initialized.
+type logBuffer struct {
+	events []fxevent.Event
+	logger fxevent.Logger
+}
+
+// LogEvents buffers or logs an event.
+func (l *logBuffer) LogEvent(event fxevent.Event) {
+	if l.logger == nil {
+		l.events = append(l.events, event)
+	} else {
+		l.logger.LogEvent(event)
+	}
+}
 
-	return &fxevent.ZapLogger{Logger: log}
+// Connect flushes out all buffered events to a logger and resets them.
+func (l *logBuffer) Connect(logger fxevent.Logger) {
+	l.logger = logger
+	for _, e := range l.events {
+		logger.LogEvent(e)
+	}
+	l.events = nil
 }
diff --git a/printer_writer.go b/printer_writer.go
index 896925f..31a5ab4 100644
--- a/printer_writer.go
+++ b/printer_writer.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2020 Uber Technologies, Inc.
+// Copyright (c) 2020-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -20,21 +20,17 @@
 
 package fx
 
-import "go.uber.org/zap/zapcore"
+import "io"
 
-type writeSyncer struct{ p Printer }
+type printerWriter struct{ p Printer }
 
-// writeSyncerFromPrinter returns an implementation of zapcore.WriteSyncer
-// used to support Logger option which implements Printer interface.
-func writeSyncerFromPrinter(p Printer) zapcore.WriteSyncer {
-	return &writeSyncer{p: p}
+// writerFromPrinter returns an implementation of io.Writer used to support
+// Logger option which implements Printer interface.
+func writerFromPrinter(p Printer) io.Writer {
+	return &printerWriter{p: p}
 }
 
-func (w *writeSyncer) Write(b []byte) (n int, err error) {
+func (w *printerWriter) Write(b []byte) (n int, err error) {
 	w.p.Printf(string(b))
 	return len(b), nil
 }
-
-func (w *writeSyncer) Sync() error {
-	return nil
-}


Test patch:
diff --git a/annotated_test.go b/annotated_test.go
index 8e9197e..c4a53b4 100644
--- a/annotated_test.go
+++ b/annotated_test.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2019 Uber Technologies, Inc.
+// Copyright (c) 2019-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -26,6 +26,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"go.uber.org/fx"
+	"go.uber.org/fx/fxevent"
 	"go.uber.org/fx/fxtest"
 )
 
@@ -74,6 +75,9 @@ func TestAnnotatedWrongUsage(t *testing.T) {
 	t.Run("In Constructor", func(t *testing.T) {
 		var in in
 		app := fx.New(
+			fx.WithLogger(func() fxevent.Logger {
+				return fxtest.NewTestLogger(t)
+			}),
 			fx.Provide(
 				func() fx.Annotated {
 					return fx.Annotated{
@@ -102,6 +106,9 @@ func TestAnnotatedWrongUsage(t *testing.T) {
 
 	t.Run("Result Type", func(t *testing.T) {
 		app := fx.New(
+			fx.WithLogger(func() fxevent.Logger {
+				return fxtest.NewTestLogger(t)
+			}),
 			fx.Provide(
 				fx.Annotated{
 					Name: "foo",
diff --git a/app_internal_test.go b/app_internal_test.go
index df903af..f5b217e 100644
--- a/app_internal_test.go
+++ b/app_internal_test.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2019 Uber Technologies, Inc.
+// Copyright (c) 2019-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -31,7 +31,7 @@ import (
 )
 
 func TestAppRun(t *testing.T) {
-	app := New()
+	app := New(NopLogger)
 	done := make(chan os.Signal)
 
 	var wg sync.WaitGroup
diff --git a/app_test.go b/app_test.go
index 94ca2da..dba8d28 100644
--- a/app_test.go
+++ b/app_test.go
@@ -26,6 +26,9 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"io/ioutil"
+	"log"
+	"os"
 	"testing"
 	"time"
 
@@ -37,11 +40,18 @@ import (
 	"go.uber.org/fx/internal/fxlog"
 	"go.uber.org/goleak"
 	"go.uber.org/multierr"
+	"go.uber.org/zap"
 )
 
-func NewForTest(t testing.TB, opts ...Option) *App {
-	testOpts := []Option{Logger(fxtest.NewTestPrinter(t))}
+func NewForTest(tb testing.TB, opts ...Option) *App {
+	testOpts := []Option{
+		// Provide both, Logger and WithLogger so that if the test
+		// WithLogger fails, we don't pollute stderr.
+		Logger(fxtest.NewTestPrinter(tb)),
+		WithLogger(func() fxevent.Logger { return fxtest.NewTestLogger(tb) }),
+	}
 	opts = append(testOpts, opts...)
+
 	return New(opts...)
 }
 
@@ -65,10 +75,11 @@ func TestNewApp(t *testing.T) {
 		// (e.g., logging) from changing halfway through our provides.
 
 		spy := new(fxlog.Spy)
-		app := fxtest.New(t, Provide(func() struct{} { return struct{}{} }), WithLogger(spy))
+		app := fxtest.New(t, Provide(func() struct{} { return struct{}{} }),
+			WithLogger(func() fxevent.Logger { return spy }))
 		defer app.RequireStart().RequireStop()
 		require.Equal(t,
-			[]string{"Provide", "Provide", "Provide", "Provide", "Running"},
+			[]string{"Provide", "Provide", "Provide", "Provide", "CustomLogger", "Running"},
 			spy.EventTypes())
 
 		assert.Contains(t, spy.Events()[0].(*fxevent.Provide).OutputTypeNames, "struct {}")
@@ -259,6 +270,143 @@ func TestNewApp(t *testing.T) {
 	})
 }
 
+func TestWithLogger(t *testing.T) {
+	t.Parallel()
+
+	t.Run("initializing custom logger", func(t *testing.T) {
+		t.Parallel()
+
+		var spy fxlog.Spy
+		app := fxtest.New(t,
+			Supply(&spy),
+			WithLogger(func(spy *fxlog.Spy) fxevent.Logger {
+				return spy
+			}),
+		)
+
+		assert.Equal(t, []string{
+			"Supply", "Provide", "Provide", "Provide", "CustomLogger",
+		}, spy.EventTypes())
+
+		spy.Reset()
+		app.RequireStart().RequireStop()
+
+		require.NoError(t, app.Err())
+
+		assert.Equal(t, []string{"Running"}, spy.EventTypes())
+	})
+
+	t.Run("error in WithLogger provider, use default", func(t *testing.T) {
+		// This test cannot be run in paralllel with the others because
+		// it hijacks stderr.
+
+		// Temporarily hijack stderr and restore it after this test so
+		// that we can assert its contents.
+		f, err := ioutil.TempFile(t.TempDir(), "stderr")
+		if err != nil {
+			t.Fatalf("could not open a file for writing")
+		}
+		defer func(oldStderr *os.File) {
+			assert.NoError(t, f.Close())
+			os.Stderr = oldStderr
+		}(os.Stderr)
+		os.Stderr = f
+
+		app := New(
+			Supply(zap.NewNop()),
+			WithLogger(&bytes.Buffer{}),
+		)
+		err = app.Err()
+		require.Error(t, err)
+		assert.Contains(t,
+			err.Error(),
+			"must provide constructor function, got  (type *bytes.Buffer)",
+		)
+
+		stderr, err := ioutil.ReadFile(f.Name())
+		require.NoError(t, err)
+
+		// Example output:
+		// [Fx] SUPPLY  *zap.Logger
+		// [Fx] ERROR   Failed to construct custom logger: fx.WithLogger() from:
+		// go.uber.org/fx_test.TestSetupLogger.func3
+		//        /Users/abg/dev/fx/app_test.go:334
+		// testing.tRunner
+		//        /usr/local/Cellar/go/1.16.4/libexec/src/testing/testing.go:1193
+		// Failed: must provide constructor function, got  (type *bytes.Buffer)
+
+		out := string(stderr)
+		assert.Contains(t, out, "[Fx] SUPPLY\t*zap.Logger\n")
+		assert.Contains(t, out, "[Fx] ERROR\t\tFailed to construct custom logger: fx.WithLogger")
+		assert.Contains(t, out, "must provide constructor function, got  (type *bytes.Buffer)\n")
+	})
+
+	t.Run("error in Provide shows logs", func(t *testing.T) {
+		t.Parallel()
+
+		var spy fxlog.Spy
+		app := New(
+			Supply(&spy),
+			WithLogger(func(spy *fxlog.Spy) fxevent.Logger {
+				return spy
+			}),
+			Provide(&bytes.Buffer{}), // not passing in a constructor.
+		)
+
+		err := app.Err()
+		require.Error(t, err)
+		assert.Contains(t,
+			err.Error(),
+			"must provide constructor function, got  (type *bytes.Buffer)",
+		)
+
+		assert.Equal(t, []string{"Supply", "ProvideError", "CustomLogger"}, spy.EventTypes())
+	})
+
+	t.Run("logger failed to build", func(t *testing.T) {
+		t.Parallel()
+
+		var buff bytes.Buffer
+		app := New(
+			Logger(log.New(&buff, "", 0)),
+			WithLogger(func() (fxevent.Logger, error) {
+				return nil, errors.New("great sadness")
+			}),
+		)
+
+		err := app.Err()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "great sadness")
+
+		out := buff.String()
+		assert.Contains(t, out, "[Fx] ERROR\t\tFailed to construct custom logger")
+	})
+
+	t.Run("logger dependency failed to build", func(t *testing.T) {
+		t.Parallel()
+
+		var buff bytes.Buffer
+		app := New(
+			Logger(log.New(&buff, "", 0)),
+			Provide(func() (*zap.Logger, error) {
+				return nil, errors.New("great sadness")
+			}),
+			WithLogger(func(log *zap.Logger) fxevent.Logger {
+				t.Errorf("WithLogger must not be called")
+				panic("must not be called")
+			}),
+		)
+
+		err := app.Err()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "great sadness")
+
+		out := buff.String()
+		assert.Contains(t, out, "[Fx] PROVIDE\t*zap.Logger")
+		assert.Contains(t, out, "[Fx] ERROR\t\tFailed to construct custom logger")
+	})
+}
+
 type errHandlerFunc func(error)
 
 func (f errHandlerFunc) HandleError(err error) { f(err) }
@@ -425,9 +573,9 @@ func TestOptions(t *testing.T) {
 		spy := new(fxlog.Spy)
 		New(
 			Provide(&bytes.Buffer{}), // error, not a constructor
-			WithLogger(spy),
+			WithLogger(func() fxevent.Logger { return spy }),
 		)
-		require.Equal(t, []string{"ProvideError"}, spy.EventTypes())
+		require.Equal(t, []string{"ProvideError", "CustomLogger"}, spy.EventTypes())
 		assert.Contains(t, spy.Events()[0].(*fxevent.ProvideError).Err.Error(), "must provide constructor function")
 	})
 }
@@ -541,7 +689,7 @@ func TestAppStart(t *testing.T) {
 	t.Run("InvokeNonFunction", func(t *testing.T) {
 		spy := new(fxlog.Spy)
 
-		app := New(WithLogger(spy), Invoke(struct{}{}))
+		app := New(WithLogger(func() fxevent.Logger { return spy }), Invoke(struct{}{}))
 		err := app.Err()
 		require.Error(t, err, "expected start failure")
 		assert.Contains(t, err.Error(), "can't invoke non-function")
@@ -554,7 +702,7 @@ func TestAppStart(t *testing.T) {
 		//         /.../go/1.13.3/libexec/src/testing/testing.go:909
 		// Failed: can't invoke non-function {} (type struct {})
 		require.Equal(t,
-			[]string{"Provide", "Provide", "Provide", "Invoke", "InvokeError"},
+			[]string{"Provide", "Provide", "Provide", "CustomLogger", "Invoke", "InvokeError"},
 			spy.EventTypes())
 		failedEvent := spy.Events()[len(spy.EventTypes())-1].(*fxevent.InvokeError)
 		assert.Contains(t, failedEvent.Err.Error(), "can't invoke non-function")
@@ -694,9 +842,16 @@ func TestAppStop(t *testing.T) {
 }
 
 func TestValidateApp(t *testing.T) {
+	// helper to use the test logger
+	validateApp := func(t *testing.T, opts ...Option) error {
+		return ValidateApp(
+			append(opts, Logger(fxtest.NewTestPrinter(t)))...,
+		)
+	}
+
 	t.Run("do not run provides on graph validation", func(t *testing.T) {
 		type type1 struct{}
-		err := ValidateApp(
+		err := validateApp(t,
 			Provide(func() *type1 {
 				t.Error("provide must not be called")
 				return nil
@@ -707,7 +862,7 @@ func TestValidateApp(t *testing.T) {
 	})
 	t.Run("do not run provides nor invokes on graph validation", func(t *testing.T) {
 		type type1 struct{}
-		err := ValidateApp(
+		err := validateApp(t,
 			Provide(func() *type1 {
 				t.Error("provide must not be called")
 				return nil
@@ -720,7 +875,7 @@ func TestValidateApp(t *testing.T) {
 	})
 	t.Run("provide depends on something not available", func(t *testing.T) {
 		type type1 struct{}
-		err := ValidateApp(
+		err := validateApp(t,
 			Provide(func(type1) int { return 0 }),
 			Invoke(func(int) error { return nil }),
 		)
@@ -733,7 +888,7 @@ func TestValidateApp(t *testing.T) {
 	t.Run("provide introduces a cycle", func(t *testing.T) {
 		type A struct{}
 		type B struct{}
-		err := ValidateApp(
+		err := validateApp(t,
 			Provide(func(A) B { return B{} }),
 			Provide(func(B) A { return A{} }),
 			Invoke(func(B) {}),
@@ -744,7 +899,7 @@ func TestValidateApp(t *testing.T) {
 	})
 	t.Run("invoke a type that's not available", func(t *testing.T) {
 		type A struct{}
-		err := ValidateApp(
+		err := validateApp(t,
 			Invoke(func(A) {}),
 		)
 		require.Error(t, err, "fx.ValidateApp should return an error on missing invoke dep")
@@ -754,7 +909,7 @@ func TestValidateApp(t *testing.T) {
 	})
 	t.Run("no error", func(t *testing.T) {
 		type A struct{}
-		err := ValidateApp(
+		err := validateApp(t,
 			Provide(func() A {
 				return A{}
 			}),
@@ -776,9 +931,9 @@ func TestDone(t *testing.T) {
 
 func TestReplaceLogger(t *testing.T) {
 	spy := new(fxlog.Spy)
-	app := fxtest.New(t, WithLogger(spy))
+	app := fxtest.New(t, WithLogger(func() fxevent.Logger { return spy }))
 	app.RequireStart().RequireStop()
-	assert.Equal(t, []string{"Provide", "Provide", "Provide", "Running"}, spy.EventTypes())
+	assert.Equal(t, []string{"Provide", "Provide", "Provide", "CustomLogger", "Running"}, spy.EventTypes())
 }
 
 func TestNopLogger(t *testing.T) {
@@ -786,6 +941,74 @@ func TestNopLogger(t *testing.T) {
 	app.RequireStart().RequireStop()
 }
 
+func TestCustomLoggerWithPrinter(t *testing.T) {
+	// If we provide both, an fx.Logger and fx.WithLogger, and the logger
+	// fails, we should fall back to the fx.Logger.
+
+	var buff bytes.Buffer
+	app := New(
+		Logger(log.New(&buff, "", 0)),
+		WithLogger(func() (fxevent.Logger, error) {
+			return nil, errors.New("great sadness")
+		}),
+	)
+	err := app.Start(context.Background())
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "great sadness")
+
+	out := buff.String()
+	assert.Contains(t, out, "failed to build fxevent.Logger")
+	assert.Contains(t, out, "great sadness")
+}
+
+func TestCustomLoggerWithLifecycle(t *testing.T) {
+	var started, stopped bool
+	defer func() {
+		assert.True(t, started, "never started")
+		assert.True(t, stopped, "never stopped")
+	}()
+
+	var buff bytes.Buffer
+	defer func() {
+		assert.Empty(t, buff.String(), "unexpectedly wrote to the fallback logger")
+	}()
+
+	var spy fxlog.Spy
+	app := New(
+		// We expect WithLogger to do its job. This means we shouldn't
+		// print anything to this buffer.
+		Logger(log.New(&buff, "", 0)),
+		WithLogger(func(lc Lifecycle) fxevent.Logger {
+			lc.Append(Hook{
+				OnStart: func(context.Context) error {
+					assert.False(t, started, "started twice")
+					started = true
+					return nil
+				},
+				OnStop: func(context.Context) error {
+					assert.False(t, stopped, "stopped twice")
+					stopped = true
+					return nil
+				},
+			})
+			return &spy
+		}),
+	)
+
+	require.NoError(t, app.Start(context.Background()))
+	require.NoError(t, app.Stop(context.Background()))
+
+	assert.Equal(t, []string{
+		"Provide",
+		"Provide",
+		"Provide",
+		"CustomLogger",
+		"LifecycleHookStart",
+		"Running",
+		"LifecycleHookStop",
+	}, spy.EventTypes())
+}
+
 type testErrorWithGraph struct {
 	graph string
 }
@@ -915,13 +1138,13 @@ func TestOptionString(t *testing.T) {
 		},
 		{
 			desc: "Logger",
-			give: WithLogger(testLogger{t}),
-			want: "fx.withLogger(TestOptionString)",
+			give: WithLogger(func() fxevent.Logger { return testLogger{t} }),
+			want: "fx.WithLogger(go.uber.org/fx_test.TestOptionString.func3())",
 		},
 		{
 			desc: "NopLogger",
 			give: NopLogger,
-			want: "fx.withLogger(NopLogger)",
+			want: "fx.WithLogger(go.uber.org/fx.glob..func1())",
 		},
 		{
 			desc: "ErrorHook",
diff --git a/error_example_test.go b/error_example_test.go
index 2ab79af..d0fd4f2 100644
--- a/error_example_test.go
+++ b/error_example_test.go
@@ -44,6 +44,7 @@ func ExampleError() {
 	}
 
 	app := fx.New(
+		fx.NopLogger,
 		newHTTPServer(),
 		fx.Invoke(func(s *http.Server) error { return s.ListenAndServe() }),
 	)
diff --git a/example_test.go b/example_test.go
index f4914d3..fc5621a 100644
--- a/example_test.go
+++ b/example_test.go
@@ -28,6 +28,7 @@ import (
 	"time"
 
 	"go.uber.org/fx"
+	"go.uber.org/fx/fxevent"
 )
 
 // NewLogger constructs a logger. It's just a regular Go function, without any
@@ -195,6 +196,16 @@ func Example() {
 		// NewMux, we also register Lifecycle hooks to start and stop an HTTP
 		// server.
 		fx.Invoke(Register),
+
+		// This is optional. With this, you can control where Fx logs
+		// its events. In this case, we're using a NopLogger to keep
+		// our test silent. Normally, you'll want to use an
+		// fxevent.ZapLogger or an fxevent.ConsoleLogger.
+		fx.WithLogger(
+			func() fxevent.Logger {
+				return fxevent.NopLogger
+			},
+		),
 	)
 
 	// In a typical application, we could just use app.Run() here. Since we
diff --git a/fxevent/console_test.go b/fxevent/console_test.go
new file mode 100644
index 0000000..9b5de0c
--- /dev/null
+++ b/fxevent/console_test.go
@@ -0,0 +1,144 @@
+// Copyright (c) 2021 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+package fxevent
+
+import (
+	"bytes"
+	"errors"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestConsoleLogger(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		give Event
+		want string
+	}{
+		{
+			name: "LifecycleHookStart",
+			give: &LifecycleHookStart{CallerName: "bytes.NewBuffer"},
+			want: "[Fx] START		bytes.NewBuffer\n",
+		},
+		{
+			name: "LifecycleHookStop",
+			give: &LifecycleHookStop{CallerName: "bytes.NewBuffer"},
+			want: "[Fx] STOP		bytes.NewBuffer\n",
+		},
+		{
+			name: "ProvideError",
+			give: &ProvideError{Err: errors.New("some error")},
+			want: "[Fx] Error after options were applied: some error\n",
+		},
+		{
+			name: "Supply",
+			give: &Supply{TypeName: "*bytes.Buffer"},
+			want: "[Fx] SUPPLY	*bytes.Buffer\n",
+		},
+		{
+			name: "Provide",
+			give: &Provide{bytes.NewBuffer, []string{"*bytes.Buffer"}},
+			want: "[Fx] PROVIDE	*bytes.Buffer <= bytes.NewBuffer()\n",
+		},
+		{
+			name: "Invoke",
+			give: &Invoke{bytes.NewBuffer},
+			want: "[Fx] INVOKE		bytes.NewBuffer()\n",
+		},
+		{
+			name: "InvokeError",
+			give: &InvokeError{
+				Function:   bytes.NewBuffer,
+				Err:        errors.New("some error"),
+				Stacktrace: "foo()\n\tbar/baz.go:42\n",
+			},
+			want: joinLines(
+				"[Fx] fx.Invoke(bytes.NewBuffer()) called from:",
+				"foo()",
+				"	bar/baz.go:42",
+				"Failed: some error",
+			),
+		},
+		{
+			name: "StartError",
+			give: &StartError{Err: errors.New("some error")},
+			want: "[Fx] ERROR		Failed to start: some error\n",
+		},
+		{
+			name: "StopSignal",
+			give: &StopSignal{Signal: os.Interrupt},
+			want: "[Fx] INTERRUPT\n",
+		},
+		{
+			name: "StopError",
+			give: &StopError{Err: errors.New("some error")},
+			want: "[Fx] ERROR		Failed to stop cleanly: some error\n",
+		},
+		{
+			name: "RollbackError",
+			give: &RollbackError{Err: errors.New("some error")},
+			want: "[Fx] ERROR		Couldn't roll back cleanly: some error\n",
+		},
+		{
+			name: "Rollback",
+			give: &Rollback{StartErr: errors.New("some error")},
+			want: "[Fx] ERROR		Start failed, rolling back: some error\n",
+		},
+		{
+			name: "Running",
+			give: &Running{},
+			want: "[Fx] RUNNING\n",
+		},
+		{
+			name: "CustomLoggerError",
+			give: &CustomLoggerError{Err: errors.New("great sadness")},
+			want: "[Fx] ERROR		Failed to construct custom logger: great sadness\n",
+		},
+		{
+			name: "CustomLogger",
+			give: &CustomLogger{
+				Function: func() Logger { panic("should not run") },
+			},
+			want: "[Fx] LOGGER	Setting up custom logger from go.uber.org/fx/fxevent.TestConsoleLogger.func1()\n",
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			var buff bytes.Buffer
+			(&ConsoleLogger{W: &buff}).LogEvent(tt.give)
+
+			assert.Equal(t, tt.want, buff.String())
+		})
+	}
+}
+
+func joinLines(lines ...string) string {
+	return strings.Join(lines, "\n") + "\n"
+}
diff --git a/fxevent/event_test.go b/fxevent/event_test.go
index 4172628..74d5582 100644
--- a/fxevent/event_test.go
+++ b/fxevent/event_test.go
@@ -42,6 +42,8 @@ func TestForCoverage(t *testing.T) {
 		&Rollback{},
 		&RollbackError{},
 		&Running{},
+		&CustomLoggerError{},
+		&CustomLogger{},
 	}
 
 	for _, e := range events {
diff --git a/fxevent/zap_test.go b/fxevent/zap_test.go
index eecdf10..a2d845a 100644
--- a/fxevent/zap_test.go
+++ b/fxevent/zap_test.go
@@ -22,129 +22,164 @@ package fxevent
 
 import (
 	"bytes"
-	"fmt"
+	"errors"
 	"os"
-	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"go.uber.org/zap/zaptest"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zaptest/observer"
 )
 
-type testLogSpy struct {
-	testing.TB
-	Messages []string
-}
-
-func newTestLogSpy(tb testing.TB) *testLogSpy {
-	return &testLogSpy{TB: tb}
-}
-
-func (t *testLogSpy) Logf(format string, args ...interface{}) {
-	// Log messages are in the format,
-	//
-	//   2017-10-27T13:03:01.000-0700	DEBUG	your message here	{data here}
-	//
-	// We strip the first part of these messages because we can't really test
-	// for the timestamp from these tests.
-	m := fmt.Sprintf(format, args...)
-	m = m[strings.IndexByte(m, '\t')+1:]
-	t.Messages = append(t.Messages, m)
-	t.TB.Log(m)
-}
+func TestZapLogger(t *testing.T) {
+	t.Parallel()
 
-func (t *testLogSpy) AssertMessages(msgs ...string) {
-	assert.Equal(t.TB, msgs, t.Messages, "logged messages did not match")
-}
+	someError := errors.New("some error")
 
-func (t *testLogSpy) Reset() {
-	t.Messages = t.Messages[:0]
-}
+	tests := []struct {
+		name        string
+		give        Event
+		wantMessage string
+		wantFields  map[string]interface{}
+	}{
+		{
+			name:        "LifecycleHookStart",
+			give:        &LifecycleHookStart{CallerName: "bytes.NewBuffer"},
+			wantMessage: "starting",
+			wantFields: map[string]interface{}{
+				"caller": "bytes.NewBuffer",
+			},
+		},
+		{
+			name:        "LifecycleHookStop",
+			give:        &LifecycleHookStop{CallerName: "bytes.NewBuffer"},
+			wantMessage: "stopping",
+			wantFields: map[string]interface{}{
+				"caller": "bytes.NewBuffer",
+			},
+		},
+		{
+			name:        "ProvideError",
+			give:        &ProvideError{Err: someError},
+			wantMessage: "error encountered while applying options",
+			wantFields: map[string]interface{}{
+				"error": "some error",
+			},
+		},
+		{
+			name:        "Supply",
+			give:        &Supply{TypeName: "*bytes.Buffer"},
+			wantMessage: "supplying",
+			wantFields: map[string]interface{}{
+				"type": "*bytes.Buffer",
+			},
+		},
+		{
+			name:        "Provide",
+			give:        &Provide{bytes.NewBuffer, []string{"*bytes.Buffer"}},
+			wantMessage: "providing",
+			wantFields: map[string]interface{}{
+				"constructor": "bytes.NewBuffer()",
+				"type":        "*bytes.Buffer",
+			},
+		},
+		{
+			name:        "Invoke",
+			give:        &Invoke{bytes.NewBuffer},
+			wantMessage: "invoke",
+			wantFields: map[string]interface{}{
+				"function": "bytes.NewBuffer()",
+			},
+		},
+		{
+			name:        "InvokeError",
+			give:        &InvokeError{Function: bytes.NewBuffer, Err: someError},
+			wantMessage: "fx.Invoke failed",
+			wantFields: map[string]interface{}{
+				"error":    "some error",
+				"stack":    "",
+				"function": "bytes.NewBuffer()",
+			},
+		},
+		{
+			name:        "StartError",
+			give:        &StartError{Err: someError},
+			wantMessage: "failed to start",
+			wantFields: map[string]interface{}{
+				"error": "some error",
+			},
+		},
+		{
+			name:        "StopSignal",
+			give:        &StopSignal{Signal: os.Interrupt},
+			wantMessage: "received signal",
+			wantFields: map[string]interface{}{
+				"signal": "INTERRUPT",
+			},
+		},
+		{
+			name:        "StopError",
+			give:        &StopError{Err: someError},
+			wantMessage: "failed to stop cleanly",
+			wantFields: map[string]interface{}{
+				"error": "some error",
+			},
+		},
+		{
+			name:        "RollbackError",
+			give:        &RollbackError{Err: someError},
+			wantMessage: "could not rollback cleanly",
+			wantFields: map[string]interface{}{
+				"error": "some error",
+			},
+		},
+		{
+			name:        "Rollback",
+			give:        &Rollback{StartErr: someError},
+			wantMessage: "startup failed, rolling back",
+			wantFields: map[string]interface{}{
+				"error": "some error",
+			},
+		},
+		{
+			name:        "Running",
+			give:        &Running{},
+			wantMessage: "running",
+			wantFields:  map[string]interface{}{},
+		},
+		{
+			name:        "CustomLoggerError",
+			give:        &CustomLoggerError{Err: someError},
+			wantMessage: "error constructing logger",
+			wantFields: map[string]interface{}{
+				"error": "some error",
+			},
+		},
+		{
+			name:        "CustomLogger",
+			give:        &CustomLogger{bytes.NewBuffer},
+			wantMessage: "installing custom fxevent.Logger",
+			wantFields: map[string]interface{}{
+				"function": "bytes.NewBuffer()",
+			},
+		},
+	}
 
-func TestZapLogger(t *testing.T) {
-	t.Parallel()
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
 
-	ts := newTestLogSpy(t)
-	logger := zaptest.NewLogger(ts)
-	zapLogger := ZapLogger{Logger: logger}
+			core, observedLogs := observer.New(zap.DebugLevel)
+			(&ZapLogger{Logger: zap.New(core)}).LogEvent(tt.give)
 
-	t.Run("LifecycleHookStart", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&LifecycleHookStart{CallerName: "bytes.NewBuffer"})
-		ts.AssertMessages("INFO\tstarting\t{\"caller\": \"bytes.NewBuffer\"}")
-	})
-	t.Run("LifecycleHookStop", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&LifecycleHookStop{CallerName: "bytes.NewBuffer"})
-		ts.AssertMessages("INFO\tstopping\t{\"caller\": \"bytes.NewBuffer\"}")
-	})
-	t.Run("ProvideError", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&ProvideError{Err: fmt.Errorf("some error")})
-		ts.AssertMessages("ERROR\terror encountered while applying options\t{\"error\": \"some error\"}")
-	})
+			logs := observedLogs.TakeAll()
+			require.Len(t, logs, 1)
+			got := logs[0]
 
-	t.Run("Supply", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&Supply{TypeName: "*bytes.Buffer"})
-		ts.AssertMessages("INFO\tsupplying\t{\"type\": \"*bytes.Buffer\"}")
-	})
-	t.Run("Provide", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&Provide{bytes.NewBuffer, []string{"*bytes.Buffer"}})
-		ts.AssertMessages("INFO\tproviding\t{\"constructor\": \"bytes.NewBuffer()\", \"type\": \"*bytes.Buffer\"}")
-	})
-	t.Run("Invoke", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&Invoke{bytes.NewBuffer})
-		ts.AssertMessages("INFO\tinvoke\t{\"function\": \"bytes.NewBuffer()\"}")
-	})
-	t.Run("InvokeError", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&InvokeError{
-			Function: bytes.NewBuffer,
-			Err:      fmt.Errorf("some error"),
-		})
-		ts.AssertMessages("ERROR\tfx.Invoke failed\t{\"error\": \"some error\", \"stack\": \"\", \"function\": \"bytes.NewBuffer()\"}")
-	})
-	t.Run("StartError", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&StartError{
-			Err: fmt.Errorf("some error"),
-		})
-		ts.AssertMessages("ERROR\tfailed to start\t{\"error\": \"some error\"}")
-	})
-	t.Run("StopSignal", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&StopSignal{
-			Signal: os.Interrupt,
-		})
-		ts.AssertMessages("INFO\treceived signal\t{\"signal\": \"INTERRUPT\"}")
-	})
-	t.Run("StopError", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&StopError{
-			Err: fmt.Errorf("some error"),
-		})
-		ts.AssertMessages("ERROR\tfailed to stop cleanly\t{\"error\": \"some error\"}")
-	})
-	t.Run("RollbackError", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&RollbackError{
-			Err: fmt.Errorf("some error"),
-		})
-		ts.AssertMessages("ERROR\tcould not rollback cleanly\t{\"error\": \"some error\"}")
-	})
-	t.Run("Rollback", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&Rollback{
-			StartErr: fmt.Errorf("some error"),
+			assert.Equal(t, tt.wantMessage, got.Message)
+			assert.Equal(t, tt.wantFields, got.ContextMap())
 		})
-		ts.AssertMessages("ERROR\tstartup failed, rolling back\t{\"error\": \"some error\"}")
-	})
-	t.Run("Running", func(t *testing.T) {
-		defer ts.Reset()
-		zapLogger.LogEvent(&Running{})
-		ts.AssertMessages("INFO\trunning")
-	})
+	}
 }
diff --git a/fxtest/app.go b/fxtest/app.go
index 1eb47b0..9ce2e8b 100644
--- a/fxtest/app.go
+++ b/fxtest/app.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2019 Uber Technologies, Inc.
+// Copyright (c) 2019-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -24,6 +24,7 @@ import (
 	"context"
 
 	"go.uber.org/fx"
+	"go.uber.org/fx/fxevent"
 )
 
 // App is a wrapper around fx.App that provides some testing helpers. By
@@ -37,7 +38,7 @@ type App struct {
 // New creates a new test application.
 func New(tb TB, opts ...fx.Option) *App {
 	allOpts := make([]fx.Option, 0, len(opts)+1)
-	allOpts = append(allOpts, fx.Logger(NewTestPrinter(tb)))
+	allOpts = append(allOpts, fx.WithLogger(func() fxevent.Logger { return NewTestLogger(tb) }))
 	allOpts = append(allOpts, opts...)
 
 	app := fx.New(allOpts...)
diff --git a/fxtest/app_test.go b/fxtest/app_test.go
index e99a97f..84d4ddc 100644
--- a/fxtest/app_test.go
+++ b/fxtest/app_test.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2019 Uber Technologies, Inc.
+// Copyright (c) 2019-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -36,7 +36,7 @@ func TestApp(t *testing.T) {
 		New(spy).RequireStart().RequireStop()
 
 		assert.Zero(t, spy.failures, "App didn't start and stop cleanly.")
-		assert.Contains(t, spy.logs.String(), "running", "Expected to write logs to TB.")
+		assert.Contains(t, spy.logs.String(), "[Fx] RUNNING", "Expected to write logs to TB.")
 	})
 
 	t.Run("NewFailure", func(t *testing.T) {
diff --git a/fxtest/lifecycle.go b/fxtest/lifecycle.go
index 8e27443..35ec2b9 100644
--- a/fxtest/lifecycle.go
+++ b/fxtest/lifecycle.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2020 Uber Technologies, Inc.
+// Copyright (c) 2020-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -30,7 +30,6 @@ import (
 	"go.uber.org/fx/internal/fxlog"
 	"go.uber.org/fx/internal/lifecycle"
 	"go.uber.org/fx/internal/testutil"
-	"go.uber.org/zap/zapcore"
 )
 
 // If a testing.T is unspecified, degarde to printing to stderr to provide
@@ -78,17 +77,15 @@ var _ fx.Lifecycle = (*Lifecycle)(nil)
 
 // NewLifecycle creates a new test lifecycle.
 func NewLifecycle(t TB) *Lifecycle {
-	var ws zapcore.WriteSyncer
+	var w io.Writer
 	if t != nil {
-		ws = testutil.WriteSyncer{T: t}
+		w = testutil.WriteSyncer{T: t}
 	} else {
-		// Retain the old behavior of printing to stderr if a testing.T
-		// is not provided.
-		ws = zapcore.AddSync(os.Stderr)
+		w = os.Stderr
 		t = &panicT{W: os.Stderr}
 	}
 	return &Lifecycle{
-		lc: lifecycle.New(fxlog.DefaultLogger(ws)),
+		lc: lifecycle.New(fxlog.DefaultLogger(w)),
 		t:  t,
 	}
 }
diff --git a/fxtest/printer.go b/fxtest/printer.go
index 7661e62..73f0f8f 100644
--- a/fxtest/printer.go
+++ b/fxtest/printer.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2019 Uber Technologies, Inc.
+// Copyright (c) 2019-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -20,7 +20,17 @@
 
 package fxtest
 
-import "go.uber.org/fx"
+import (
+	"go.uber.org/fx"
+	"go.uber.org/fx/fxevent"
+	"go.uber.org/fx/internal/fxlog"
+	"go.uber.org/fx/internal/testutil"
+)
+
+// NewTestLogger returns an fxlog.Logger that logs to the testing TB.
+func NewTestLogger(t TB) fxevent.Logger {
+	return fxlog.DefaultLogger(testutil.WriteSyncer{T: t})
+}
 
 type testPrinter struct {
 	TB
diff --git a/opt_test.go b/internal/fxlog/default.go
similarity index 84%
rename from opt_test.go
rename to internal/fxlog/default.go
index f57d0fc..a6424a6 100644
--- a/opt_test.go
+++ b/internal/fxlog/default.go
@@ -18,11 +18,15 @@
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 // THE SOFTWARE.
 
-package fx
+package fxlog
 
-import "go.uber.org/fx/fxevent"
+import (
+	"io"
 
-// WithLogger exposes logger option for tests.
-func WithLogger(l fxevent.Logger) Option {
-	return withLogger(l)
+	"go.uber.org/fx/fxevent"
+)
+
+// DefaultLogger constructs a Logger out of io.Writer.
+func DefaultLogger(w io.Writer) fxevent.Logger {
+	return &fxevent.ConsoleLogger{W: w}
 }
diff --git a/internal/fxlog/fxlog_test.go b/internal/fxlog/default_test.go
similarity index 96%
rename from internal/fxlog/fxlog_test.go
rename to internal/fxlog/default_test.go
index b747e4f..2c36f8b 100644
--- a/internal/fxlog/fxlog_test.go
+++ b/internal/fxlog/default_test.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2020 Uber Technologies, Inc.
+// Copyright (c) 2020-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
diff --git a/internal/testutil/writer.go b/internal/testutil/writer.go
index 87ea53e..695cd67 100644
--- a/internal/testutil/writer.go
+++ b/internal/testutil/writer.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2020 Uber Technologies, Inc.
+// Copyright (c) 2020-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -20,7 +20,9 @@
 
 package testutil
 
-import "go.uber.org/zap/zapcore"
+import (
+	"go.uber.org/zap/zapcore"
+)
 
 // TestingT is a subset of the testing.T interface.
 type TestingT interface {
diff --git a/log_test.go b/log_test.go
new file mode 100644
index 0000000..ab23cc1
--- /dev/null
+++ b/log_test.go
@@ -0,0 +1,55 @@
+// Copyright (c) 2021 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+package fx
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/fx/fxevent"
+	"go.uber.org/fx/internal/fxlog"
+)
+
+func TestLogBufferConnect(t *testing.T) {
+	spy := new(fxlog.Spy)
+	event := &fxevent.Running{}
+	lb := &logBuffer{
+		events: []fxevent.Event{event},
+		logger: nil,
+	}
+
+	lb.Connect(spy)
+	assert.Equal(t, spy.Events(), []fxevent.Event{event})
+}
+
+func TestLogBufferLog(t *testing.T) {
+	spy := new(fxlog.Spy)
+	event := &fxevent.Running{}
+	lb := &logBuffer{
+		events: nil,
+		logger: nil,
+	}
+
+	lb.LogEvent(event)
+
+	lb.Connect(spy)
+	assert.Equal(t, spy.Events(), []fxevent.Event{event})
+}
diff --git a/populate_example_test.go b/populate_example_test.go
index dfb873d..a56da5e 100644
--- a/populate_example_test.go
+++ b/populate_example_test.go
@@ -40,7 +40,7 @@ func ExamplePopulate() {
 	var user Username
 	app := fx.New(
 		UserModule,
-
+		fx.NopLogger, // silence test output
 		fx.Populate(&user),
 	)
 	if err := app.Start(context.Background()); err != nil {