Repository: uber-go/fx
Pull request #717 (2020-10-27 00:10:56)

Add structured logger to internal Fx logging.
Introducing a structured logger, zap, to the internal logger. This is to address a common user complaint where logs are just a wall of text which is hard to parse through and find the error that's causing application to fail.

In this diff we implement current public API `Logger` via Zap, and adding private (for now) `withLogger` and `exitCode` which will be public once we iterate on the new logging interface. 

Following is a list of all new messages and fields.

msg | field | field
-- | -- | --
providing | type | constructor
supplying | type |  
invoke | function |  
received signal | signal |  
startup failed, rolling back | error |  
could not rollback cleanly | error |  
error encountered while applying options | error |  
fx.Invoke failed | function |  error |
failed to start | error |  
failed to stop cleanly | error |  
running |   |  



Ref T5424311 GO-77

codecov[bot]: # [Codecov](https://codecov.io/gh/uber-go/fx/pull/717?src=pr&el=h1) Report
> Merging [#717](https://codecov.io/gh/uber-go/fx/pull/717?src=pr&el=desc) (b470e66) into [master](https://codecov.io/gh/uber-go/fx/commit/3f60a46f14ed6bade5cff987aab9788f20fae8b5?el=desc) (3f60a46) will **decrease** coverage by `1.39%`.
> The diff coverage is `90.62%`.

[![Impacted file tree graph](https://codecov.io/gh/uber-go/fx/pull/717/graphs/tree.svg?width=650&height=150&src=pr&token=PYY9PSfaC6)](https://codecov.io/gh/uber-go/fx/pull/717?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #717      +/-   ##
==========================================
- Coverage   98.30%   96.90%   -1.40%     
==========================================
  Files          16       16              
  Lines         590      517      -73     
==========================================
- Hits          580      501      -79     
- Misses          8       14       +6     
  Partials        2        2              
```


| [Impacted Files](https://codecov.io/gh/uber-go/fx/pull/717?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [fxtest/printer.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-Znh0ZXN0L3ByaW50ZXIuZ28=) | `66.66% <0.00%> (-33.34%)` | :arrow_down: |
| [app.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-YXBwLmdv) | `94.25% <80.48%> (-3.28%)` | :arrow_down: |
| [internal/fxlog/fxlog.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-aW50ZXJuYWwvZnhsb2cvZnhsb2cuZ28=) | `96.96% <100.00%> (+1.73%)` | :arrow_up: |
| [internal/fxlog/spy.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-aW50ZXJuYWwvZnhsb2cvc3B5Lmdv) | `100.00% <100.00%> (ø)` | |
| [internal/lifecycle/lifecycle.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-aW50ZXJuYWwvbGlmZWN5Y2xlL2xpZmVjeWNsZS5nbw==) | `100.00% <100.00%> (ø)` | |
| [supply.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-c3VwcGx5Lmdv) | `100.00% <0.00%> (ø)` | |
| [extract.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-ZXh0cmFjdC5nbw==) | `100.00% <0.00%> (ø)` | |
| [populate.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-cG9wdWxhdGUuZ28=) | `100.00% <0.00%> (ø)` | |
| [shutdown.go](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree#diff-c2h1dGRvd24uZ28=) | `100.00% <0.00%> (ø)` | |
| ... and [8 more](https://codecov.io/gh/uber-go/fx/pull/717/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/uber-go/fx/pull/717?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/uber-go/fx/pull/717?src=pr&el=footer). Last update [3f60a46...b470e66](https://codecov.io/gh/uber-go/fx/pull/717?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.gitignore b/.gitignore
index 607f44f..c79d305 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,4 @@ coverage.txt
 *.pprof
 /.bin
 /.cache
-/bin
+/bin
\ No newline at end of file
diff --git a/app.go b/app.go
index 2095d87..3369a94 100644
--- a/app.go
+++ b/app.go
@@ -261,13 +261,35 @@ 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 fxlog.Logger) Option {
+	return withLoggerOption{l}
+}
+
+type withLoggerOption struct{ l fxlog.Logger }
+
+func (l withLoggerOption) apply(app *App) {
+	app.log = l.l
+}
+
+func (l withLoggerOption) String() string {
+	return fmt.Sprintf("fx.withLogger(%v)", l.l)
+}
+
 // Printer is the interface required by Fx's logging backend. It's implemented
 // by most loggers, including the one bundled with the standard library.
+//
+// Note, this will be deprecate with next release and you will need to implement
+// fxlog.Logger interface instead.
 type Printer interface {
 	Printf(string, ...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.
 func Logger(p Printer) Option {
 	return loggerOption{p}
 }
@@ -275,8 +297,8 @@ func Logger(p Printer) Option {
 type loggerOption struct{ p Printer }
 
 func (l loggerOption) apply(app *App) {
-	app.logger = &fxlog.Logger{Printer: l.p}
-	app.lifecycle = &lifecycleWrapper{lifecycle.New(app.logger)}
+	np := writeSyncerFromPrinter(l.p)
+	app.log = fxlog.DefaultLogger(np) // assuming np is thread-safe.
 }
 
 func (l loggerOption) String() string {
@@ -285,13 +307,13 @@ 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.
-var NopLogger = Logger(nopLogger{})
+//
+// Note, when withLogger is public we will make the change here as well.
+var NopLogger = withLogger(nopLogger{})
 
 type nopLogger struct{}
 
-func (l nopLogger) Printf(string, ...interface{}) {
-	return
-}
+func (nopLogger) Log(fxlog.Entry) {}
 
 func (nopLogger) String() string { return "NopLogger" }
 
@@ -335,7 +357,7 @@ type App struct {
 	lifecycle    *lifecycleWrapper
 	provides     []provide
 	invokes      []invoke
-	logger       *fxlog.Logger
+	log          fxlog.Logger
 	startTimeout time.Duration
 	stopTimeout  time.Duration
 	errorHooks   []ErrorHandler
@@ -432,12 +454,10 @@ func ValidateApp(opts ...Option) error {
 // registered via Invoke options. See the documentation of the App struct for
 // details on the application's initialization, startup, and shutdown logic.
 func New(opts ...Option) *App {
-	logger := fxlog.New()
-	lc := &lifecycleWrapper{lifecycle.New(logger)}
+	logger := fxlog.DefaultLogger(os.Stderr)
 
 	app := &App{
-		lifecycle:    lc,
-		logger:       logger,
+		log:          logger,
 		startTimeout: DefaultTimeout,
 		stopTimeout:  DefaultTimeout,
 	}
@@ -445,6 +465,7 @@ func New(opts ...Option) *App {
 	for _, opt := range opts {
 		opt.apply(app)
 	}
+	app.lifecycle = &lifecycleWrapper{lifecycle.New(app.log)}
 
 	app.container = dig.New(
 		dig.DeferAcyclicVerification(),
@@ -464,7 +485,10 @@ func New(opts ...Option) *App {
 	app.provide(provide{Target: app.dotGraph, Stack: frames})
 
 	if app.err != nil {
-		app.logger.Printf("Error after options were applied: %v", app.err)
+		fxlog.Info(
+			"error encountered while applying options",
+			fxlog.Err(app.err),
+		).Write(app.log)
 		return app
 	}
 
@@ -618,9 +642,16 @@ func (app *App) provide(p provide) {
 
 	switch {
 	case p.IsSupply:
-		app.logger.PrintSupply(constructor)
+		for _, rtype := range fxreflect.ReturnTypes(constructor) {
+			fxlog.Info("supplying", fxlog.Field{Key: "type", Value: rtype}).Write(app.log)
+		}
 	default:
-		app.logger.PrintProvide(constructor)
+		for _, rtype := range fxreflect.ReturnTypes(constructor) {
+			fxlog.Info("providing",
+				fxlog.Field{Key: "type", Value: rtype},
+				fxlog.Field{Key: "constructor", Value: fxreflect.FuncName(constructor)},
+			).Write(app.log)
+		}
 	}
 
 	if _, ok := constructor.(Option); ok {
@@ -681,7 +712,7 @@ func (app *App) executeInvokes() error {
 	for _, i := range app.invokes {
 		fn := i.Target
 		fname := fxreflect.FuncName(fn)
-		app.logger.Printf("INVOKE\t\t%s", fname)
+		fxlog.Info("invoke", fxlog.F("function", fname)).Write(app.log)
 
 		var err error
 		if _, ok := fn.(Option); ok {
@@ -693,7 +724,11 @@ func (app *App) executeInvokes() error {
 		}
 
 		if err != nil {
-			app.logger.Printf("fx.Invoke(%v) called from:\n%+vFailed: %v", fname, i.Stack, err)
+			fxlog.Error(
+				"fx.Invoke failed",
+				fxlog.F("function", fname),
+				fxlog.Err(err),
+			).WithStack(i.Stack.String()).Write(app.log)
 			return err
 		}
 	}
@@ -706,16 +741,19 @@ func (app *App) run(done <-chan os.Signal) {
 	defer cancel()
 
 	if err := app.Start(startCtx); err != nil {
-		app.logger.Fatalf("ERROR\t\tFailed to start: %v", err)
+		fxlog.Error("failed to start", fxlog.Err(err)).Write(app.log)
+		os.Exit(1)
 	}
-
-	app.logger.PrintSignal(<-done)
+	sig := (<-done).String()
+	fxlog.Info("received signal", fxlog.F("signal", strings.ToUpper(sig))).Write(app.log)
 
 	stopCtx, cancel := context.WithTimeout(context.Background(), app.StopTimeout())
 	defer cancel()
 
 	if err := app.Stop(stopCtx); err != nil {
-		app.logger.Fatalf("ERROR\t\tFailed to stop cleanly: %v", err)
+		fxlog.Error("failed to stop cleanly",
+			fxlog.Err(err)).Write(app.log)
+		os.Exit(1)
 	}
 }
 
@@ -727,16 +765,17 @@ func (app *App) start(ctx context.Context) error {
 
 	// Attempt to start cleanly.
 	if err := app.lifecycle.Start(ctx); err != nil {
-		// Start failed, roll back.
-		app.logger.Printf("ERROR\t\tStart failed, rolling back: %v", err)
+		// Start failed, rolling back.
+		fxlog.Info("startup failed, rolling back", fxlog.Err(err)).Write(app.log)
 		if stopErr := app.lifecycle.Stop(ctx); stopErr != nil {
-			app.logger.Printf("ERROR\t\tCouldn't rollback cleanly: %v", stopErr)
+			fxlog.Info("could not rollback cleanly", fxlog.Err(stopErr)).Write(app.log)
+
 			return multierr.Append(err, stopErr)
 		}
 		return err
 	}
+	fxlog.Info("running").Write(app.log)
 
-	app.logger.Printf("RUNNING")
 	return nil
 }
 
diff --git a/go.mod b/go.mod
index cc264e7..ef37ef1 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,8 @@ require (
 	github.com/stretchr/testify v1.4.0
 	go.uber.org/dig v1.10.0
 	go.uber.org/goleak v0.10.0
-	go.uber.org/multierr v1.4.0
+	go.uber.org/multierr v1.5.0
+	go.uber.org/zap v1.16.0
 	golang.org/x/lint v0.0.0-20190930215403-16217165b5de
 	golang.org/x/tools v0.0.0-20191114200427-caa0b0f7d508 // indirect
 )
diff --git a/go.sum b/go.sum
index 18347db..17358f8 100644
--- a/go.sum
+++ b/go.sum
@@ -11,6 +11,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -20,14 +21,20 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
 go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/dig v1.10.0 h1:yLmDDj9/zuDjv3gz8GQGviXMs9TfysIUMUilCpgzUJY=
 go.uber.org/dig v1.10.0/go.mod h1:X34SnWGr8Fyla9zQNO2GSO2D+TIuqB14OS8JhYocIyw=
 go.uber.org/goleak v0.10.0 h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4=
 go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI=
 go.uber.org/multierr v1.4.0 h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=
 go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
+go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A=
+go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
+go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM=
+go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
diff --git a/internal/fxlog/fxlog.go b/internal/fxlog/fxlog.go
index 4ea0e89..bc6d28e 100644
--- a/internal/fxlog/fxlog.go
+++ b/internal/fxlog/fxlog.go
@@ -21,68 +21,117 @@
 package fxlog
 
 import (
-	"fmt"
-	"log"
-	"os"
-	"strings"
-
-	"go.uber.org/fx/internal/fxreflect"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
 )
 
-var _exit = func() { os.Exit(1) }
+// Level is the level of logging used by Logger.
+type Level int
+
+const (
+	// InfoLevel is Info logging level used to log messages via zap.
+	InfoLevel Level = iota
+	// ErrorLevel is Info logging level used to log messages via zap.
+	ErrorLevel
+)
 
-// Printer is a formatting printer.
-type Printer interface {
-	Printf(string, ...interface{})
+// Entry is an entry to be later serialized into zap message and fields.
+type Entry struct {
+	Level   Level
+	Message string
+	Fields  []Field
+	Stack   string
 }
 
-// New returns a new Logger backed by the standard library's log package.
-func New() *Logger {
-	return &Logger{log.New(os.Stderr, "", log.LstdFlags)}
+// WithStack mutates an Entry to add a Stack field.
+func (e Entry) WithStack(stack string) Entry {
+	e.Stack = stack
+	return e
 }
 
-// A Logger writes output to standard error.
-type Logger struct {
-	Printer
+func (e Entry) Write(logger Logger) {
+	logger.Log(e)
 }
 
-// Printf logs a formatted Fx line.
-func (l *Logger) Printf(format string, v ...interface{}) {
-	l.Printer.Printf(prepend(format), v...)
+// Err is a helper for error Fields.
+func Err(value error) Field {
+	return F("error", value)
 }
 
-// PrintProvide logs a type provided into the dig.Container.
-func (l *Logger) PrintProvide(t interface{}) {
-	for _, rtype := range fxreflect.ReturnTypes(t) {
-		l.Printf("PROVIDE\t%s <= %s", rtype, fxreflect.FuncName(t))
+// F is a constructor for a Field.
+func F(key string, value interface{}) Field {
+	return Field{
+		Key:   key,
+		Value: value,
 	}
 }
 
-// PrintSupply logs a type supplied directly into the dig.Container
-// by the given constructor function.
-func (l *Logger) PrintSupply(constructor interface{}) {
-	for _, rtype := range fxreflect.ReturnTypes(constructor) {
-		l.Printf("SUPPLY\t%s", rtype)
+// Field defines a field used inside an internal logging Entry.
+type Field struct {
+	Key   string
+	Value interface{}
+}
+
+// Logger defines interface used for logging.
+type Logger interface {
+	Log(entry Entry)
+}
+
+var _ Logger = (*zapLogger)(nil)
+
+type zapLogger struct {
+	logger *zap.Logger
+}
+
+func encodeFields(fields []Field, stack string) []zap.Field {
+	var fs []zap.Field
+	for _, field := range fields {
+		fs = append(fs, zap.Any(field.Key, field.Value))
+	}
+	if stack != "" {
+		fs = append(fs, zap.String("stack", stack))
 	}
+
+	return fs
 }
 
-// PrintSignal logs an os.Signal.
-func (l *Logger) PrintSignal(signal os.Signal) {
-	l.Printf(strings.ToUpper(signal.String()))
+func (l *zapLogger) Log(entry Entry) {
+	switch entry.Level {
+	case InfoLevel:
+		l.logger.Info(entry.Message, encodeFields(entry.Fields, entry.Stack)...)
+	case ErrorLevel:
+		l.logger.Error(entry.Message, encodeFields(entry.Fields, entry.Stack)...)
+	}
 }
 
-// Panic logs an Fx line then panics.
-func (l *Logger) Panic(err error) {
-	l.Printer.Printf(prepend(err.Error()))
-	panic(err)
+// DefaultLogger constructs a Logger out of io.Writer.
+func DefaultLogger(ws zapcore.WriteSyncer) Logger {
+	zcore := zapcore.NewCore(
+		zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
+		ws,
+		zap.NewAtomicLevel(),
+	)
+	log := zap.New(zcore)
+
+	return &zapLogger{
+		logger: log,
+	}
 }
 
-// Fatalf logs an Fx line then fatals.
-func (l *Logger) Fatalf(format string, v ...interface{}) {
-	l.Printer.Printf(prepend(format), v...)
-	_exit()
+// Info creates a logging Info entry.
+func Info(msg string, fields ...Field) Entry {
+	return Entry{
+		Level:   InfoLevel,
+		Message: msg,
+		Fields:  fields,
+	}
 }
 
-func prepend(str string) string {
-	return fmt.Sprintf("[Fx] %s", str)
+// Error creates a logging Error entry.
+func Error(msg string, fields ...Field) Entry {
+	return Entry{
+		Level:   ErrorLevel,
+		Message: msg,
+		Fields:  fields,
+	}
 }
diff --git a/internal/fxlog/spy.go b/internal/fxlog/spy.go
index 839f9ed..acb33f8 100644
--- a/internal/fxlog/spy.go
+++ b/internal/fxlog/spy.go
@@ -23,40 +23,62 @@ package fxlog
 import (
 	"fmt"
 	"strings"
+
+	"go.uber.org/zap"
 )
 
 // Spy is an Fx Printer that captures logged statements. It may be used in
 // tests of Fx logs.
 type Spy struct {
-	msgs []string
+	entries []Entry
 }
 
-var _ Printer = &Spy{}
+var _ Logger = &Spy{}
 
-// Printf logs the given message, formatting it in printf-style.
-func (s *Spy) Printf(msg string, args ...interface{}) {
-	s.msgs = append(s.msgs, fmt.Sprintf(msg, args...))
+// Log append an Entry.
+func (s *Spy) Log(entry Entry) {
+	s.entries = append(s.entries, entry)
 }
 
 // Messages returns a copy of all captured messages.
-func (s *Spy) Messages() []string {
-	msgs := make([]string, len(s.msgs))
-	copy(msgs, s.msgs)
+func (s *Spy) Messages() []Entry {
+	msgs := make([]Entry, len(s.entries))
+	copy(msgs, s.entries)
 	return msgs
 }
 
 // String returns all logged messages as a single newline-delimited string.
 func (s *Spy) String() string {
-	if len(s.msgs) == 0 {
+	if len(s.entries) == 0 {
 		return ""
 	}
+	var msg strings.Builder
+	for _, m := range s.entries {
+		fmt.Fprintf(&msg, m.Message)
+		for _, f := range m.Fields {
+			// extra space before f.Key to separate out message.
+			fmt.Fprintf(&msg, "\t%s: %v", f.Key, f.Value)
+		}
+		if m.Stack != "" {
+			fmt.Fprintf(&msg, "\t%q", m.Stack)
+		}
+		msg.WriteString("\n")
+	}
+
+	return msg.String()
+}
+
+// Fields returns all Fields members of entries.
+func (s *Spy) Fields() []zap.Field {
+	var fields []zap.Field
+	for _, e := range s.entries {
+		fields = append(fields, encodeFields(e.Fields, "")...)
+	}
 
-	// trailing newline because all log entries should have a newline
-	// after them.
-	return strings.Join(s.msgs, "\n") + "\n"
+	return fields
 }
 
 // Reset clears all messages from the Spy.
 func (s *Spy) Reset() {
-	s.msgs = s.msgs[:0]
+	s.entries = s.entries[:0]
 }
diff --git a/internal/lifecycle/lifecycle.go b/internal/lifecycle/lifecycle.go
index ded22ed..2d7fe18 100644
--- a/internal/lifecycle/lifecycle.go
+++ b/internal/lifecycle/lifecycle.go
@@ -23,9 +23,10 @@ package lifecycle
 import (
 	"context"
 
+	"go.uber.org/multierr"
+
 	"go.uber.org/fx/internal/fxlog"
 	"go.uber.org/fx/internal/fxreflect"
-	"go.uber.org/multierr"
 )
 
 // A Hook is a pair of start and stop callbacks, either of which can be nil,
@@ -38,16 +39,13 @@ type Hook struct {
 
 // Lifecycle coordinates application lifecycle hooks.
 type Lifecycle struct {
-	logger     *fxlog.Logger
+	logger     fxlog.Logger
 	hooks      []Hook
 	numStarted int
 }
 
 // New constructs a new Lifecycle.
-func New(logger *fxlog.Logger) *Lifecycle {
-	if logger == nil {
-		logger = fxlog.New()
-	}
+func New(logger fxlog.Logger) *Lifecycle {
 	return &Lifecycle{logger: logger}
 }
 
@@ -62,7 +60,10 @@ func (l *Lifecycle) Append(hook Hook) {
 func (l *Lifecycle) Start(ctx context.Context) error {
 	for _, hook := range l.hooks {
 		if hook.OnStart != nil {
-			l.logger.Printf("START\t\t%s()", hook.caller)
+			fxlog.Info("starting", fxlog.Field{
+				Key:   "caller",
+				Value: hook.caller,
+			}).Write(l.logger)
 			if err := hook.OnStart(ctx); err != nil {
 				return err
 			}
@@ -82,7 +83,10 @@ func (l *Lifecycle) Stop(ctx context.Context) error {
 		if hook.OnStop == nil {
 			continue
 		}
-		l.logger.Printf("STOP\t\t%s()", hook.caller)
+		fxlog.Info("stopping", fxlog.Field{
+			Key:   "caller",
+			Value: hook.caller,
+		}).Write(l.logger)
 		if err := hook.OnStop(ctx); err != nil {
 			// For best-effort cleanup, keep going after errors.
 			errs = append(errs, err)
diff --git a/printer_writer.go b/printer_writer.go
new file mode 100644
index 0000000..896925f
--- /dev/null
+++ b/printer_writer.go
@@ -0,0 +1,40 @@
+// Copyright (c) 2020 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 "go.uber.org/zap/zapcore"
+
+type writeSyncer 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}
+}
+
+func (w *writeSyncer) 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/app_test.go b/app_test.go
index 6423bee..d61256a 100644
--- a/app_test.go
+++ b/app_test.go
@@ -29,13 +29,14 @@ import (
 	"testing"
 	"time"
 
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	. "go.uber.org/fx"
 	"go.uber.org/fx/fxtest"
 	"go.uber.org/fx/internal/fxlog"
 	"go.uber.org/multierr"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
 )
 
 func NewForTest(t testing.TB, opts ...Option) *App {
@@ -64,9 +65,14 @@ 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{}{} }), Logger(spy))
+		app := fxtest.New(t, Provide(func() struct{} { return struct{}{} }), WithLogger(spy))
 		defer app.RequireStart().RequireStop()
-		assert.Contains(t, spy.String(), "PROVIDE\tstruct {}")
+		assert.Contains(t, spy.String(), "providing")
+		assert.Contains(t, spy.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "struct {}",
+		})
 	})
 
 	t.Run("CircularGraphReturnsError", func(t *testing.T) {
@@ -420,9 +426,15 @@ func TestOptions(t *testing.T) {
 		spy := new(fxlog.Spy)
 		New(
 			Provide(&bytes.Buffer{}), // error, not a constructor
-			Logger(spy),
+			WithLogger(spy),
 		)
-		assert.Contains(t, spy.String(), "must provide constructor function")
+		assert.Contains(t, spy.String(), "error encountered while applying options")
+		fields := spy.Fields()
+		assert.Len(t, fields, 1)
+		s := fields[0]
+		assert.Equal(t, "error", s.Key)
+		assert.Equal(t, zapcore.ErrorType, s.Type)
+		assert.Contains(t, fmt.Sprint(s.Interface), "must provide constructor function")
 	})
 }
 
@@ -534,7 +546,7 @@ func TestAppStart(t *testing.T) {
 	t.Run("InvokeNonFunction", func(t *testing.T) {
 		spy := new(fxlog.Spy)
 
-		app := New(Logger(spy), Invoke(struct{}{}))
+		app := New(WithLogger(spy), Invoke(struct{}{}))
 		err := app.Err()
 		require.Error(t, err, "expected start failure")
 		assert.Contains(t, err.Error(), "can't invoke non-function")
@@ -547,10 +559,13 @@ func TestAppStart(t *testing.T) {
 		//         /.../go/1.13.3/libexec/src/testing/testing.go:909
 		// Failed: can't invoke non-function {} (type struct {})
 		output := spy.String()
-		assert.Contains(t, output, "fx.Invoke({}) called from:")
+		fields := spy.Fields()
+		assert.Len(t, fields, 9)
+
+		assert.Contains(t, output, "fx.Invoke failed\tfunction:")
 		assert.Contains(t, output, "go.uber.org/fx_test.TestAppStart")
 		assert.Contains(t, output, "fx/app_test.go")
-		assert.Contains(t, output, "Failed: can't invoke non-function")
+		assert.Contains(t, output, "can't invoke non-function")
 	})
 
 	t.Run("ProvidingAProvideShouldFail", func(t *testing.T) {
@@ -765,9 +780,9 @@ func TestDone(t *testing.T) {
 
 func TestReplaceLogger(t *testing.T) {
 	spy := new(fxlog.Spy)
-	app := fxtest.New(t, Logger(spy))
+	app := fxtest.New(t, WithLogger(spy))
 	app.RequireStart().RequireStop()
-	assert.Contains(t, spy.String(), "RUNNING")
+	assert.Contains(t, spy.String(), "running")
 }
 
 func TestNopLogger(t *testing.T) {
@@ -905,13 +920,13 @@ func TestOptionString(t *testing.T) {
 		},
 		{
 			desc: "Logger",
-			give: Logger(testLogger{t}),
-			want: "fx.Logger(TestOptionString)",
+			give: WithLogger(testLogger{t}),
+			want: "fx.withLogger(TestOptionString)",
 		},
 		{
 			desc: "NopLogger",
 			give: NopLogger,
-			want: "fx.Logger(NopLogger)",
+			want: "fx.withLogger(NopLogger)",
 		},
 		{
 			desc: "ErrorHook",
@@ -945,6 +960,10 @@ func (l testLogger) Printf(s string, args ...interface{}) {
 	l.t.Logf(s, args...)
 }
 
+func (l testLogger) Log(entry fxlog.Entry) {
+	l.t.Logf(entry.Message)
+}
+
 func (l testLogger) String() string {
 	return l.t.Name()
 }
diff --git a/fxtest/fxtest.go b/fxtest/fxtest.go
index 8aa7ce0..ec2c795 100644
--- a/fxtest/fxtest.go
+++ b/fxtest/fxtest.go
@@ -22,8 +22,10 @@ package fxtest
 
 import (
 	"context"
+	"os"
 
 	"go.uber.org/fx"
+	"go.uber.org/fx/internal/fxlog"
 	"go.uber.org/fx/internal/lifecycle"
 )
 
@@ -97,7 +99,7 @@ type Lifecycle struct {
 // NewLifecycle creates a new test lifecycle.
 func NewLifecycle(t TB) *Lifecycle {
 	return &Lifecycle{
-		lc: lifecycle.New(nil),
+		lc: lifecycle.New(fxlog.DefaultLogger(os.Stderr)),
 		t:  t,
 	}
 }
diff --git a/fxtest/fxtest_test.go b/fxtest/fxtest_test.go
index 570e351..8e47de2 100644
--- a/fxtest/fxtest_test.go
+++ b/fxtest/fxtest_test.go
@@ -65,7 +65,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(), "running", "Expected to write logs to TB.")
 	})
 
 	t.Run("NewFailure", func(t *testing.T) {
diff --git a/internal/fxlog/fxlog_test.go b/internal/fxlog/fxlog_test.go
index 0a49fb3..05b6c9f 100644
--- a/internal/fxlog/fxlog_test.go
+++ b/internal/fxlog/fxlog_test.go
@@ -22,52 +22,63 @@ package fxlog
 
 import (
 	"bytes"
-	"errors"
 	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"go.uber.org/dig"
 	"go.uber.org/fx/internal/fxlog/foovendor"
-	sample "go.uber.org/fx/internal/fxlog/sample.git"
+	"go.uber.org/fx/internal/fxlog/sample.git"
+	"go.uber.org/fx/internal/fxreflect"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
 )
 
-// stubs the exit call, returns a function that restores a real exit function
-// and asserts that the stub was called.
-func stubExit() func(testing.TB) {
-	prev := _exit
-	var called bool
-	_exit = func() { called = true }
-	return func(t testing.TB) {
-		assert.True(t, called, "Exit wasn't called.")
-		_exit = prev
-	}
-}
-
 func TestNew(t *testing.T) {
-	assert.NotPanics(t, func() { New() })
+	assert.NotPanics(t, func() { DefaultLogger(os.Stderr) })
 }
 
 func TestPrint(t *testing.T) {
 	sink := new(Spy)
-	logger := &Logger{sink}
 
 	t.Run("printf", func(t *testing.T) {
 		sink.Reset()
-		logger.Printf("foo %d", 42)
-		assert.Equal(t, "[Fx] foo 42\n", sink.String())
+		Info("foo 42").Write(sink)
+		assert.Equal(t, "foo 42\n", sink.String())
 	})
 
 	t.Run("printProvide", func(t *testing.T) {
 		sink.Reset()
-		logger.PrintProvide(bytes.NewBuffer)
-		assert.Equal(t, "[Fx] PROVIDE\t*bytes.Buffer <= bytes.NewBuffer()\n", sink.String())
+		for _, rtype := range fxreflect.ReturnTypes(bytes.NewBuffer) {
+			Info("providing",
+				Field{Key: "type", Value: rtype},
+				Field{Key: "constructor", Value: fxreflect.FuncName(bytes.NewBuffer)},
+			).Write(sink)
+		}
+		assert.Contains(t, sink.String(), "providing")
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "*bytes.Buffer",
+		})
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "constructor",
+			Type:   zapcore.StringType,
+			String: "bytes.NewBuffer()",
+		})
 	})
 
 	t.Run("PrintSupply", func(t *testing.T) {
 		sink.Reset()
-		logger.PrintSupply(func() *bytes.Buffer { return bytes.NewBuffer(nil) })
-		assert.Equal(t, "[Fx] SUPPLY\t*bytes.Buffer\n", sink.String())
+		for _, rtype := range fxreflect.ReturnTypes(func() *bytes.Buffer { return bytes.NewBuffer(nil) }) {
+			Info("supplying", Field{Key: "type", Value: rtype}).Write(sink)
+		}
+		assert.Contains(t, sink.String(), "supplying")
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "*bytes.Buffer",
+		})
 	})
 
 	t.Run("printExpandsTypesInOut", func(t *testing.T) {
@@ -82,19 +93,45 @@ func TestPrint(t *testing.T) {
 			B
 			C `name:"foo"`
 		}
-		logger.PrintProvide(func() Ret { return Ret{} })
-
-		s := sink.String()
-		assert.Contains(t, s, "[Fx] PROVIDE\t*fxlog.A <=")
-		assert.Contains(t, s, "[Fx] PROVIDE\tfxlog.B <=")
-		assert.Contains(t, s, "[Fx] PROVIDE\tfxlog.C:foo <=")
+		for _, rtype := range fxreflect.ReturnTypes(func() Ret { return Ret{} }) {
+			Info("providing",
+				Field{Key: "type", Value: rtype},
+				Field{Key: "constructor", Value: fxreflect.FuncName(func() Ret { return Ret{} })},
+			).Write(sink)
+		}
+		assert.Contains(t, sink.String(), "providing")
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "*fxlog.A",
+		})
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "fxlog.B",
+		})
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "fxlog.C:foo",
+		})
 	})
 
 	t.Run("printHandlesDotGitCorrectly", func(t *testing.T) {
 		sink.Reset()
-		logger.PrintProvide(sample.New)
+		for _, rtype := range fxreflect.ReturnTypes(sample.New) {
+			Info("providing",
+				Field{Key: "type", Value: rtype},
+				Field{Key: "constructor", Value: fxreflect.FuncName(sample.New)},
+			).Write(sink)
+		}
 		assert.NotContains(t, sink.String(), "%2e", "should not be url encoded")
-		assert.Contains(t, sink.String(), "sample.git", "should contain a dot")
+		assert.Contains(t, sink.String(), "providing", "should contain a dot")
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "constructor",
+			Type:   zapcore.StringType,
+			String: "go.uber.org/fx/internal/fxlog/sample.git.New()",
+		})
 	})
 
 	t.Run("printOutNamedTypes", func(t *testing.T) {
@@ -109,61 +146,90 @@ func TestPrint(t *testing.T) {
 			A1 *A `name:"primary"`
 			A2 *A `name:"secondary"`
 		}
-		logger.PrintProvide(func() Ret { return Ret{} })
-
-		s := sink.String()
-		assert.Contains(t, s, "[Fx] PROVIDE\t*fxlog.A:primary <=")
-		assert.Contains(t, s, "[Fx] PROVIDE\t*fxlog.A:secondary <=")
-		assert.Contains(t, s, "[Fx] PROVIDE\t*fxlog.B:foo <=")
+		for _, rtype := range fxreflect.ReturnTypes(func() Ret { return Ret{} }) {
+			Info("providing",
+				Field{Key: "type", Value: rtype},
+				Field{Key: "constructor", Value: fxreflect.FuncName(func() Ret { return Ret{} })},
+			).Write(sink)
+		}
+		assert.Contains(t, sink.String(), "providing")
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "*fxlog.A:primary",
+		})
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "*fxlog.A:secondary",
+		})
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "*fxlog.B:foo",
+		})
 	})
 
 	t.Run("printProvideInvalid", func(t *testing.T) {
 		sink.Reset()
 		// No logging on invalid provides, since we're already logging an error
 		// elsewhere.
-		logger.PrintProvide(bytes.NewBuffer(nil))
+		for _, rtype := range fxreflect.ReturnTypes(bytes.NewBuffer(nil)) {
+			Info("providing",
+				Field{Key: "type", Value: rtype},
+				Field{Key: "constructor", Value: fxreflect.FuncName(bytes.NewBuffer(nil))},
+			).Write(sink)
+		}
 		assert.Equal(t, "", sink.String())
 	})
 
 	t.Run("printStripsVendorPath", func(t *testing.T) {
 		sink.Reset()
 		// assert is vendored within fx and is a good test case
-		logger.PrintProvide(assert.New)
-		assert.Contains(
-			t, sink.String(),
-			"*assert.Assertions <= github.com/stretchr/testify/assert.New()")
+		for _, rtype := range fxreflect.ReturnTypes(assert.New) {
+			Info("providing",
+				Field{Key: "type", Value: rtype},
+				Field{Key: "constructor", Value: fxreflect.FuncName(assert.New)},
+			).Write(sink)
+		}
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "constructor",
+			Type:   zapcore.StringType,
+			String: "github.com/stretchr/testify/assert.New()",
+		})
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "*assert.Assertions",
+		})
 	})
 
 	t.Run("printFooVendorPath", func(t *testing.T) {
 		sink.Reset()
 		// assert is vendored within fx and is a good test case
-		logger.PrintProvide(foovendor.New)
-		assert.Contains(
-			t, sink.String(),
-			"string <= go.uber.org/fx/internal/fxlog/foovendor.New()")
+		for _, rtype := range fxreflect.ReturnTypes(foovendor.New) {
+			Info("providing",
+				Field{Key: "type", Value: rtype},
+				Field{Key: "constructor", Value: fxreflect.FuncName(foovendor.New)},
+			).Write(sink)
+		}
+		assert.Contains(t, sink.String(), "providing")
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "type",
+			Type:   zapcore.StringType,
+			String: "string",
+		})
+		assert.Contains(t, sink.Fields(), zap.Field{
+			Key:    "constructor",
+			Type:   zapcore.StringType,
+			String: "go.uber.org/fx/internal/fxlog/foovendor.New()",
+		})
 	})
 
 	t.Run("printSignal", func(t *testing.T) {
 		sink.Reset()
-		logger.PrintSignal(os.Interrupt)
-		assert.Equal(t, "[Fx] INTERRUPT\n", sink.String())
+		sig := os.Interrupt.String()
+		Info(sig).Write(sink)
+		assert.Equal(t, "interrupt\n", sink.String())
 	})
 }
-
-func TestPanic(t *testing.T) {
-	sink := new(Spy)
-	logger := &Logger{sink}
-	assert.Panics(t, func() { logger.Panic(errors.New("foo")) })
-	assert.Equal(t, "[Fx] foo\n", sink.String())
-}
-
-func TestFatal(t *testing.T) {
-	sink := new(Spy)
-	logger := &Logger{sink}
-
-	undo := stubExit()
-	defer undo(t)
-
-	logger.Fatalf("foo %d", 42)
-	assert.Equal(t, "[Fx] foo 42\n", sink.String())
-}
diff --git a/internal/fxlog/spy_test.go b/internal/fxlog/spy_test.go
index 72702d4..7bda0c9 100644
--- a/internal/fxlog/spy_test.go
+++ b/internal/fxlog/spy_test.go
@@ -21,7 +21,6 @@
 package fxlog
 
 import (
-	"errors"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -35,19 +34,41 @@ func TestSpy(t *testing.T) {
 		assert.Empty(t, s.String(), "string must be empty")
 	})
 
-	s.Printf("foo bar")
+	s.Log(Entry{
+		Message: "foo bar",
+	})
 	t.Run("unformatted message", func(t *testing.T) {
-		assert.Equal(t, []string{"foo bar"}, s.Messages(), "messages must match")
+		assert.Equal(t, []Entry{
+			{Message: "foo bar"},
+		}, s.Messages(), "messages must match")
 		assert.Equal(t, "foo bar\n", s.String(), "string must match")
 	})
 
-	s.Printf("something went wrong: %v", errors.New("great sadness"))
+	s.Log(Entry{
+		Message: "something went wrong",
+		Fields: []Field{
+			{
+				Key:   "error",
+				Value: "great sadness",
+			},
+		},
+	})
 	t.Run("formatted message", func(t *testing.T) {
-		assert.Equal(t, []string{
-			"foo bar",
-			"something went wrong: great sadness",
+		assert.Equal(t, []Entry{
+			{
+				Message: "foo bar",
+			},
+			{
+				Message: "something went wrong",
+				Fields: []Field{
+					{
+						Key:   "error",
+						Value: "great sadness",
+					},
+				},
+			},
 		}, s.Messages())
-		assert.Equal(t, "foo bar\nsomething went wrong: great sadness\n", s.String())
+		assert.Equal(t, "foo bar\nsomething went wrong\terror: great sadness\n", s.String())
 	})
 
 	s.Reset()
@@ -56,9 +77,15 @@ func TestSpy(t *testing.T) {
 		assert.Empty(t, s.String(), "string must be empty")
 	})
 
-	s.Printf("baz qux")
+	s.Log(Entry{
+		Message: "baz qux",
+	})
 	t.Run("use after reset", func(t *testing.T) {
-		assert.Equal(t, []string{"baz qux"}, s.Messages(), "messages must match")
+		assert.Equal(t, []Entry{
+			{
+				Message: "baz qux",
+			},
+		}, s.Messages(), "messages must match")
 		assert.Equal(t, "baz qux\n", s.String(), "string must match")
 	})
 }
diff --git a/internal/lifecycle/lifecycle_test.go b/internal/lifecycle/lifecycle_test.go
index 9ace3b6..b1935f2 100644
--- a/internal/lifecycle/lifecycle_test.go
+++ b/internal/lifecycle/lifecycle_test.go
@@ -23,6 +23,7 @@ package lifecycle
 import (
 	"context"
 	"errors"
+	"os"
 	"testing"
 
 	"go.uber.org/fx/internal/fxlog"
@@ -33,7 +34,7 @@ import (
 
 func TestLifecycleStart(t *testing.T) {
 	t.Run("ExecutesInOrder", func(t *testing.T) {
-		l := New(nil)
+		l := New(fxlog.DefaultLogger(os.Stderr))
 		count := 0
 
 		l.Append(Hook{
@@ -55,7 +56,7 @@ func TestLifecycleStart(t *testing.T) {
 		assert.Equal(t, 2, count)
 	})
 	t.Run("ErrHaltsChainAndRollsBack", func(t *testing.T) {
-		l := New(nil)
+		l := New(fxlog.DefaultLogger(os.Stderr))
 		err := errors.New("a starter error")
 		starterCount := 0
 		stopperCount := 0
@@ -104,7 +105,7 @@ func TestLifecycleStart(t *testing.T) {
 
 func TestLifecycleStop(t *testing.T) {
 	t.Run("DoesNothingWithoutHooks", func(t *testing.T) {
-		l := &Lifecycle{logger: fxlog.New()}
+		l := &Lifecycle{logger: fxlog.DefaultLogger(os.Stderr)}
 		assert.Nil(t, l.Stop(context.Background()), "no lifecycle hooks should have resulted in stop returning nil")
 	})
 
@@ -115,13 +116,13 @@ func TestLifecycleStop(t *testing.T) {
 				return nil
 			},
 		}
-		l := &Lifecycle{logger: fxlog.New()}
+		l := &Lifecycle{logger: fxlog.DefaultLogger(os.Stderr)}
 		l.Append(hook)
 		l.Stop(context.Background())
 	})
 
 	t.Run("ExecutesInReverseOrder", func(t *testing.T) {
-		l := &Lifecycle{logger: fxlog.New()}
+		l := &Lifecycle{logger: fxlog.DefaultLogger(os.Stderr)}
 		count := 2
 
 		l.Append(Hook{
@@ -145,7 +146,7 @@ func TestLifecycleStop(t *testing.T) {
 	})
 
 	t.Run("ErrDoesntHaltChain", func(t *testing.T) {
-		l := New(nil)
+		l := New(fxlog.DefaultLogger(os.Stderr))
 		count := 0
 
 		l.Append(Hook{
@@ -167,7 +168,7 @@ func TestLifecycleStop(t *testing.T) {
 		assert.Equal(t, 2, count)
 	})
 	t.Run("GathersAllErrs", func(t *testing.T) {
-		l := New(nil)
+		l := New(fxlog.DefaultLogger(os.Stderr))
 
 		err := errors.New("some stop error")
 		err2 := errors.New("some other stop error")
@@ -187,7 +188,7 @@ func TestLifecycleStop(t *testing.T) {
 		assert.Equal(t, multierr.Combine(err, err2), l.Stop(context.Background()))
 	})
 	t.Run("AllowEmptyHooks", func(t *testing.T) {
-		l := New(nil)
+		l := New(fxlog.DefaultLogger(os.Stderr))
 		l.Append(Hook{})
 		l.Append(Hook{})
 
@@ -196,7 +197,7 @@ func TestLifecycleStop(t *testing.T) {
 	})
 
 	t.Run("DoesNothingIfStartFailed", func(t *testing.T) {
-		l := New(nil)
+		l := New(fxlog.DefaultLogger(os.Stderr))
 		err := errors.New("some start error")
 
 		l.Append(Hook{
diff --git a/opt_test.go b/opt_test.go
new file mode 100644
index 0000000..dd56b37
--- /dev/null
+++ b/opt_test.go
@@ -0,0 +1,28 @@
+// Copyright (c) 2020 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 "go.uber.org/fx/internal/fxlog"
+
+// WithLogger exposes logger option for tests.
+func WithLogger(l fxlog.Logger) Option {
+	return withLogger(l)
+}