Repository: uber-go/fx
Pull request #747 (2021-06-22 06:44:35)

Expose public structured logging interface: fxevent
As part of #733 we need to finalize and expose a logging interface for
users to implement. After some internal discussion, we decided that
instead of the `Field{string, interface{}}` approach that can only be
used for logging, we'll emit structured events.

    package fxevent

    type Logger interface{ LogEvent(Event) }
    type Event interface{ /* closed interface */ }

Each event that Fx was previously logging will now be emitted as a
structured event type that implements the Event interface.

    package fxevent

    type Provide struct{ Constructor interface{} }

This gives implementations a lot more control over what is logged and
how, and leaves us room for implementing metrics and other
observability on Fx events in the future.

We also completely replace existing internal logging implementation to
use this new interface.

Note that #733 will need to be rebased on top of this.

Ref GO-666

codecov[bot]: # [Codecov](https://codecov.io/gh/uber-go/fx/pull/747?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) Report
> Merging [#747](https://codecov.io/gh/uber-go/fx/pull/747?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) (318a74f) into [master](https://codecov.io/gh/uber-go/fx/commit/67e707cf373d679af06fbb7a6e4b7da169b8801d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) (67e707c) will **increase** coverage by `0.14%`.
> The diff coverage is `96.77%`.

[![Impacted file tree graph](https://codecov.io/gh/uber-go/fx/pull/747/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/747?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     #747      +/-   ##
==========================================
+ Coverage   97.71%   97.86%   +0.14%     
==========================================
  Files          19       19              
  Lines         525      515      -10     
==========================================
- Hits          513      504       -9     
+ Misses         10        9       -1     
  Partials        2        2              
```


| [Impacted Files](https://codecov.io/gh/uber-go/fx/pull/747?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) | Coverage Δ | |
|---|---|---|
| [app.go](https://codecov.io/gh/uber-go/fx/pull/747/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-YXBwLmdv) | `95.83% <87.50%> (+0.33%)` | :arrow_up: |
| [internal/fxlog/fxlog.go](https://codecov.io/gh/uber-go/fx/pull/747/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-aW50ZXJuYWwvZnhsb2cvZnhsb2cuZ28=) | `100.00% <100.00%> (ø)` | |
| [internal/fxlog/spy.go](https://codecov.io/gh/uber-go/fx/pull/747/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-aW50ZXJuYWwvZnhsb2cvc3B5Lmdv) | `100.00% <100.00%> (ø)` | |
| [internal/lifecycle/lifecycle.go](https://codecov.io/gh/uber-go/fx/pull/747/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% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/uber-go/fx/pull/747?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/747?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go). Last update [67e707c...318a74f](https://codecov.io/gh/uber-go/fx/pull/747?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).

sywhang: > We might end up with events for #746.

Yeah, I like this change so I'll hold off on #746 until this one is merged and rebase that branch using these. 

Patch:
diff --git a/app.go b/app.go
index e1e25de..35fe585 100644
--- a/app.go
+++ b/app.go
@@ -33,6 +33,7 @@ import (
 	"time"
 
 	"go.uber.org/dig"
+	"go.uber.org/fx/fxevent"
 	"go.uber.org/fx/internal/fxlog"
 	"go.uber.org/fx/internal/fxreflect"
 	"go.uber.org/fx/internal/lifecycle"
@@ -262,11 +263,11 @@ func (t stopTimeoutOption) String() string {
 
 // withLogger will stay private until we export fxlog.Logger once
 // we finalize the API.
-func withLogger(l fxlog.Logger) Option {
+func withLogger(l fxevent.Logger) Option {
 	return withLoggerOption{l}
 }
 
-type withLoggerOption struct{ l fxlog.Logger }
+type withLoggerOption struct{ l fxevent.Logger }
 
 func (l withLoggerOption) apply(app *App) {
 	app.log = l.l
@@ -280,7 +281,7 @@ func (l withLoggerOption) String() string {
 // 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.
+// fxevent.Logger interface instead.
 type Printer interface {
 	Printf(string, ...interface{})
 }
@@ -308,13 +309,7 @@ func (l loggerOption) String() string {
 // 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(nopLogger{})
-
-type nopLogger struct{}
-
-func (nopLogger) Log(fxlog.Entry) {}
-
-func (nopLogger) String() string { return "NopLogger" }
+var NopLogger = withLogger(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
@@ -356,7 +351,7 @@ type App struct {
 	lifecycle    *lifecycleWrapper
 	provides     []provide
 	invokes      []invoke
-	log          fxlog.Logger
+	log          fxevent.Logger
 	startTimeout time.Duration
 	stopTimeout  time.Duration
 	errorHooks   []ErrorHandler
@@ -375,7 +370,8 @@ type provide struct {
 	Stack fxreflect.Stack
 
 	// IsSupply is true when the Target constructor was emitted by fx.Supply.
-	IsSupply bool
+	IsSupply   bool
+	SupplyType reflect.Type // set only if IsSupply
 }
 
 // invoke is a single invocation request to Fx.
@@ -484,10 +480,8 @@ func New(opts ...Option) *App {
 	app.provide(provide{Target: app.dotGraph, Stack: frames})
 
 	if app.err != nil {
-		fxlog.Info(
-			"error encountered while applying options",
-			fxlog.Err(app.err),
-		).Write(app.log)
+		app.log.LogEvent(&fxevent.ProvideError{Err: app.err})
+
 		return app
 	}
 
@@ -641,16 +635,9 @@ func (app *App) provide(p provide) {
 
 	switch {
 	case p.IsSupply:
-		for _, rtype := range fxreflect.ReturnTypes(constructor) {
-			fxlog.Info("supplying", fxlog.Field{Key: "type", Value: rtype}).Write(app.log)
-		}
+		app.log.LogEvent(&fxevent.Supply{TypeName: p.SupplyType.String()})
 	default:
-		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)
-		}
+		app.log.LogEvent(&fxevent.Provide{Constructor: constructor})
 	}
 
 	if _, ok := constructor.(Option); ok {
@@ -710,8 +697,7 @@ func (app *App) executeInvokes() error {
 
 	for _, i := range app.invokes {
 		fn := i.Target
-		fname := fxreflect.FuncName(fn)
-		fxlog.Info("invoke", fxlog.F("function", fname)).Write(app.log)
+		app.log.LogEvent(&fxevent.Invoke{Function: fn})
 
 		var err error
 		if _, ok := fn.(Option); ok {
@@ -723,11 +709,12 @@ func (app *App) executeInvokes() error {
 		}
 
 		if err != nil {
-			fxlog.Error(
-				"fx.Invoke failed",
-				fxlog.F("function", fname),
-				fxlog.Err(err),
-			).WithStack(i.Stack.String()).Write(app.log)
+			app.log.LogEvent(&fxevent.InvokeError{
+				Function:   fn,
+				Err:        err,
+				Stacktrace: i.Stack.String(),
+			})
+
 			return err
 		}
 	}
@@ -740,18 +727,17 @@ func (app *App) run(done <-chan os.Signal) {
 	defer cancel()
 
 	if err := app.Start(startCtx); err != nil {
-		fxlog.Error("failed to start", fxlog.Err(err)).Write(app.log)
+		app.log.LogEvent(&fxevent.StartError{Err: err})
 		os.Exit(1)
 	}
-	sig := (<-done).String()
-	fxlog.Info("received signal", fxlog.F("signal", strings.ToUpper(sig))).Write(app.log)
+	sig := <-done
+	app.log.LogEvent(&fxevent.StopSignal{Signal: sig})
 
 	stopCtx, cancel := context.WithTimeout(context.Background(), app.StopTimeout())
 	defer cancel()
 
 	if err := app.Stop(stopCtx); err != nil {
-		fxlog.Error("failed to stop cleanly",
-			fxlog.Err(err)).Write(app.log)
+		app.log.LogEvent(&fxevent.StopError{Err: err})
 		os.Exit(1)
 	}
 }
@@ -765,15 +751,15 @@ func (app *App) start(ctx context.Context) error {
 	// Attempt to start cleanly.
 	if err := app.lifecycle.Start(ctx); err != nil {
 		// Start failed, rolling back.
-		fxlog.Info("startup failed, rolling back", fxlog.Err(err)).Write(app.log)
+		app.log.LogEvent(&fxevent.Rollback{StartErr: err})
 		if stopErr := app.lifecycle.Stop(ctx); stopErr != nil {
-			fxlog.Info("could not rollback cleanly", fxlog.Err(stopErr)).Write(app.log)
+			app.log.LogEvent(&fxevent.RollbackError{Err: stopErr})
 
 			return multierr.Append(err, stopErr)
 		}
 		return err
 	}
-	fxlog.Info("running").Write(app.log)
+	app.log.LogEvent(&fxevent.Running{})
 
 	return nil
 }
diff --git a/fxevent/event.go b/fxevent/event.go
new file mode 100644
index 0000000..b50b899
--- /dev/null
+++ b/fxevent/event.go
@@ -0,0 +1,101 @@
+// 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 "os"
+
+// Event defines an event emitted by fx.
+type Event interface {
+	event() // Only fxlog can implement this interface.
+}
+
+// Passing events by type to make Event hashable in the future.
+func (*LifecycleHookStart) event() {}
+func (*LifecycleHookStop) event()  {}
+func (*ProvideError) event()       {}
+func (*Supply) event()             {}
+func (*Provide) event()            {}
+func (*Invoke) event()             {}
+func (*InvokeError) event()        {}
+func (*StartError) event()         {}
+func (*StopSignal) event()         {}
+func (*StopError) event()          {}
+func (*Rollback) event()           {}
+func (*RollbackError) event()      {}
+func (*Running) event()            {}
+
+// LifecycleHookStart is emitted whenever an OnStart hook is executed
+type LifecycleHookStart struct {
+	CallerName string
+}
+
+// LifecycleHookStop is emitted whenever an OnStart hook is executed
+type LifecycleHookStop struct {
+	CallerName string
+}
+
+// ProvideError is emitted whenever there is an error applying options.
+type ProvideError struct {
+	Err error
+}
+
+// Supply is emitted whenever a Provide was called with a constructor provided
+// by fx.Supply.
+type Supply struct {
+	TypeName string
+}
+
+// Provide is emitted whenever Provide was called and is not provided by fx.Supply.
+type Provide struct {
+	Constructor interface{}
+}
+
+// Invoke is emitted whenever a function is invoked.
+type Invoke struct {
+	Function interface{}
+}
+
+// InvokeError is emitted when fx.Invoke has failed.
+type InvokeError struct {
+	Function   interface{}
+	Err        error
+	Stacktrace string
+}
+
+// StartError is emitted right before exiting after failing to start.
+type StartError struct{ Err error }
+
+// StopSignal is emitted whenever application receives a signal after
+// starting the application.
+type StopSignal struct{ Signal os.Signal }
+
+// StopError is emitted whenever we fail to stop cleanly.
+type StopError struct{ Err error }
+
+// Rollback is emitted whenever a service fails to start.
+type Rollback struct{ StartErr error }
+
+// RollbackError is emitted whenever we fail to rollback cleanly after
+// a start error.
+type RollbackError struct{ Err error }
+
+// Running is emitted whenever an application is started successfully.
+type Running struct{}
diff --git a/fxevent/logger.go b/fxevent/logger.go
new file mode 100644
index 0000000..0822d0d
--- /dev/null
+++ b/fxevent/logger.go
@@ -0,0 +1,38 @@
+// 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
+
+// Logger defines interface used for logging.
+type Logger interface {
+	// LogEvent is called when a logging event is emitted.
+	LogEvent(Event)
+}
+
+// NopLogger is an Fx event logger that ignores all messages.
+var NopLogger = nopLogger{}
+
+type nopLogger struct{}
+
+var _ Logger = nopLogger{}
+
+func (nopLogger) LogEvent(Event) {}
+
+func (nopLogger) String() string { return "NopLogger" }
diff --git a/fxevent/zap.go b/fxevent/zap.go
new file mode 100644
index 0000000..ada0511
--- /dev/null
+++ b/fxevent/zap.go
@@ -0,0 +1,75 @@
+// 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 (
+	"strings"
+
+	"go.uber.org/fx/internal/fxreflect"
+	"go.uber.org/zap"
+)
+
+// ZapLogger is an Fx event logger that logs events to Zap.
+type ZapLogger struct {
+	Logger *zap.Logger
+}
+
+var _ Logger = (*ZapLogger)(nil)
+
+// LogEvent logs the given event to the provided Zap logger.
+func (l *ZapLogger) LogEvent(event Event) {
+	switch e := event.(type) {
+	case *LifecycleHookStart:
+		l.Logger.Info("starting", zap.String("caller", e.CallerName))
+	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))
+	case *Supply:
+		l.Logger.Info("supplying", zap.String("type", e.TypeName))
+	case *Provide:
+		for _, rtype := range fxreflect.ReturnTypes(e.Constructor) {
+			l.Logger.Info("providing",
+				zap.String("constructor", fxreflect.FuncName(e.Constructor)),
+				zap.String("type", rtype),
+			)
+		}
+	case *Invoke:
+		l.Logger.Info("invoke", zap.String("function", fxreflect.FuncName(e.Function)))
+	case *InvokeError:
+		l.Logger.Error("fx.Invoke failed",
+			zap.Error(e.Err),
+			zap.String("stack", e.Stacktrace),
+			zap.String("function", fxreflect.FuncName(e.Function)))
+	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())))
+	case *StopError:
+		l.Logger.Error("failed to stop cleanly", zap.Error(e.Err))
+	case *RollbackError:
+		l.Logger.Error("could not rollback cleanly", zap.Error(e.Err))
+	case *Rollback:
+		l.Logger.Error("startup failed, rolling back", zap.Error(e.StartErr))
+	case *Running:
+		l.Logger.Info("running")
+	}
+}
diff --git a/internal/fxlog/fxlog.go b/internal/fxlog/fxlog.go
index bc6d28e..c6070d5 100644
--- a/internal/fxlog/fxlog.go
+++ b/internal/fxlog/fxlog.go
@@ -21,91 +21,13 @@
 package fxlog
 
 import (
+	"go.uber.org/fx/fxevent"
 	"go.uber.org/zap"
 	"go.uber.org/zap/zapcore"
 )
 
-// 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
-)
-
-// Entry is an entry to be later serialized into zap message and fields.
-type Entry struct {
-	Level   Level
-	Message string
-	Fields  []Field
-	Stack   string
-}
-
-// WithStack mutates an Entry to add a Stack field.
-func (e Entry) WithStack(stack string) Entry {
-	e.Stack = stack
-	return e
-}
-
-func (e Entry) Write(logger Logger) {
-	logger.Log(e)
-}
-
-// Err is a helper for error Fields.
-func Err(value error) Field {
-	return F("error", value)
-}
-
-// F is a constructor for a Field.
-func F(key string, value interface{}) Field {
-	return Field{
-		Key:   key,
-		Value: value,
-	}
-}
-
-// 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
-}
-
-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)...)
-	}
-}
-
 // DefaultLogger constructs a Logger out of io.Writer.
-func DefaultLogger(ws zapcore.WriteSyncer) Logger {
+func DefaultLogger(ws zapcore.WriteSyncer) fxevent.Logger {
 	zcore := zapcore.NewCore(
 		zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
 		ws,
@@ -113,25 +35,5 @@ func DefaultLogger(ws zapcore.WriteSyncer) Logger {
 	)
 	log := zap.New(zcore)
 
-	return &zapLogger{
-		logger: log,
-	}
-}
-
-// Info creates a logging Info entry.
-func Info(msg string, fields ...Field) Entry {
-	return Entry{
-		Level:   InfoLevel,
-		Message: msg,
-		Fields:  fields,
-	}
-}
-
-// Error creates a logging Error entry.
-func Error(msg string, fields ...Field) Entry {
-	return Entry{
-		Level:   ErrorLevel,
-		Message: msg,
-		Fields:  fields,
-	}
+	return &fxevent.ZapLogger{Logger: log}
 }
diff --git a/internal/fxlog/spy.go b/internal/fxlog/spy.go
index acb33f8..4c9c8e0 100644
--- a/internal/fxlog/spy.go
+++ b/internal/fxlog/spy.go
@@ -21,64 +21,41 @@
 package fxlog
 
 import (
-	"fmt"
-	"strings"
+	"reflect"
 
-	"go.uber.org/zap"
+	"go.uber.org/fx/fxevent"
 )
 
-// Spy is an Fx Printer that captures logged statements. It may be used in
+// Spy is an Fx event logger that captures logged statements. It may be used in
 // tests of Fx logs.
 type Spy struct {
-	entries []Entry
+	events []fxevent.Event
 }
 
-var _ Logger = &Spy{}
+var _ fxevent.Logger = &Spy{}
 
-// Log append an Entry.
-func (s *Spy) Log(entry Entry) {
-	s.entries = append(s.entries, entry)
+// LogEvent appends an Event.
+func (s *Spy) LogEvent(event fxevent.Event) {
+	s.events = append(s.events, event)
 }
 
-// Messages returns a copy of all captured messages.
-func (s *Spy) Messages() []Entry {
-	msgs := make([]Entry, len(s.entries))
-	copy(msgs, s.entries)
-	return msgs
+// Events returns all captured events.
+func (s *Spy) Events() []fxevent.Event {
+	events := make([]fxevent.Event, len(s.events))
+	copy(events, s.events)
+	return events
 }
 
-// String returns all logged messages as a single newline-delimited string.
-func (s *Spy) String() string {
-	if len(s.entries) == 0 {
-		return ""
+// EventTypes returns all captured event types.
+func (s *Spy) EventTypes() []string {
+	types := make([]string, len(s.events))
+	for i, e := range s.events {
+		types[i] = reflect.TypeOf(e).Elem().Name()
 	}
-	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, "")...)
-	}
-
-	return fields
+	return types
 }
 
 // Reset clears all messages from the Spy.
 func (s *Spy) Reset() {
-	s.entries = s.entries[:0]
+	s.events = s.events[:0]
 }
diff --git a/internal/lifecycle/lifecycle.go b/internal/lifecycle/lifecycle.go
index 2d7fe18..ca4fdde 100644
--- a/internal/lifecycle/lifecycle.go
+++ b/internal/lifecycle/lifecycle.go
@@ -23,10 +23,9 @@ package lifecycle
 import (
 	"context"
 
-	"go.uber.org/multierr"
-
-	"go.uber.org/fx/internal/fxlog"
+	"go.uber.org/fx/fxevent"
 	"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,
@@ -39,13 +38,13 @@ type Hook struct {
 
 // Lifecycle coordinates application lifecycle hooks.
 type Lifecycle struct {
-	logger     fxlog.Logger
+	logger     fxevent.Logger
 	hooks      []Hook
 	numStarted int
 }
 
 // New constructs a new Lifecycle.
-func New(logger fxlog.Logger) *Lifecycle {
+func New(logger fxevent.Logger) *Lifecycle {
 	return &Lifecycle{logger: logger}
 }
 
@@ -60,16 +59,14 @@ func (l *Lifecycle) Append(hook Hook) {
 func (l *Lifecycle) Start(ctx context.Context) error {
 	for _, hook := range l.hooks {
 		if hook.OnStart != nil {
-			fxlog.Info("starting", fxlog.Field{
-				Key:   "caller",
-				Value: hook.caller,
-			}).Write(l.logger)
+			l.logger.LogEvent(&fxevent.LifecycleHookStart{CallerName: hook.caller})
 			if err := hook.OnStart(ctx); err != nil {
 				return err
 			}
 		}
 		l.numStarted++
 	}
+
 	return nil
 }
 
@@ -83,14 +80,12 @@ func (l *Lifecycle) Stop(ctx context.Context) error {
 		if hook.OnStop == nil {
 			continue
 		}
-		fxlog.Info("stopping", fxlog.Field{
-			Key:   "caller",
-			Value: hook.caller,
-		}).Write(l.logger)
+		l.logger.LogEvent(&fxevent.LifecycleHookStop{CallerName: hook.caller})
 		if err := hook.OnStop(ctx); err != nil {
 			// For best-effort cleanup, keep going after errors.
 			errs = append(errs, err)
 		}
 	}
+
 	return multierr.Combine(errs...)
 }
diff --git a/supply.go b/supply.go
index e7b127b..ae16129 100644
--- a/supply.go
+++ b/supply.go
@@ -55,34 +55,39 @@ import (
 // Supply panics if a value (or annotation target) is an untyped nil or an error.
 func Supply(values ...interface{}) Option {
 	constructors := make([]interface{}, len(values)) // one function per value
-
+	types := make([]reflect.Type, len(values))
 	for i, value := range values {
 		switch value := value.(type) {
 		case Annotated:
-			value.Target = newSupplyConstructor(value.Target)
+			var typ reflect.Type
+			value.Target, typ = newSupplyConstructor(value.Target)
 			constructors[i] = value
+			types[i] = typ
 		default:
-			constructors[i] = newSupplyConstructor(value)
+			constructors[i], types[i] = newSupplyConstructor(value)
 		}
 	}
 
 	return supplyOption{
 		Targets: constructors,
+		Types:   types,
 		Stack:   fxreflect.CallerStack(1, 0),
 	}
 }
 
 type supplyOption struct {
 	Targets []interface{}
+	Types   []reflect.Type // type of value produced by constructor[i]
 	Stack   fxreflect.Stack
 }
 
 func (o supplyOption) apply(app *App) {
-	for _, target := range o.Targets {
+	for i, target := range o.Targets {
 		app.provides = append(app.provides, provide{
-			Target:   target,
-			Stack:    o.Stack,
-			IsSupply: true,
+			Target:     target,
+			Stack:      o.Stack,
+			IsSupply:   true,
+			SupplyType: o.Types[i],
 		})
 	}
 }
@@ -102,7 +107,7 @@ func (o supplyOption) String() string {
 }
 
 // Returns a function that takes no parameters, and returns the given value.
-func newSupplyConstructor(value interface{}) interface{} {
+func newSupplyConstructor(value interface{}) (interface{}, reflect.Type) {
 	switch value.(type) {
 	case nil:
 		panic("untyped nil passed to fx.Supply")
@@ -110,7 +115,8 @@ func newSupplyConstructor(value interface{}) interface{} {
 		panic("error value passed to fx.Supply")
 	}
 
-	returnTypes := []reflect.Type{reflect.TypeOf(value)}
+	typ := reflect.TypeOf(value)
+	returnTypes := []reflect.Type{typ}
 	returnValues := []reflect.Value{reflect.ValueOf(value)}
 
 	ft := reflect.FuncOf([]reflect.Type{}, returnTypes, false)
@@ -118,5 +124,5 @@ func newSupplyConstructor(value interface{}) interface{} {
 		return returnValues
 	})
 
-	return fv.Interface()
+	return fv.Interface(), typ
 }


Test patch:
diff --git a/app_test.go b/app_test.go
index d61256a..d1fa6e3 100644
--- a/app_test.go
+++ b/app_test.go
@@ -32,11 +32,11 @@ 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"
 	"go.uber.org/fx/internal/fxlog"
+	"go.uber.org/fx/internal/fxreflect"
 	"go.uber.org/multierr"
-	"go.uber.org/zap"
-	"go.uber.org/zap/zapcore"
 )
 
 func NewForTest(t testing.TB, opts ...Option) *App {
@@ -67,12 +67,10 @@ func TestNewApp(t *testing.T) {
 		spy := new(fxlog.Spy)
 		app := fxtest.New(t, Provide(func() struct{} { return struct{}{} }), WithLogger(spy))
 		defer app.RequireStart().RequireStop()
-		assert.Contains(t, spy.String(), "providing")
-		assert.Contains(t, spy.Fields(), zap.Field{
-			Key:    "type",
-			Type:   zapcore.StringType,
-			String: "struct {}",
-		})
+		require.Equal(t,
+			[]string{"Provide", "Provide", "Provide", "Provide", "Running"},
+			spy.EventTypes())
+		assert.Contains(t, fxreflect.ReturnTypes(spy.Events()[0].(*fxevent.Provide).Constructor), "struct {}")
 	})
 
 	t.Run("CircularGraphReturnsError", func(t *testing.T) {
@@ -428,13 +426,8 @@ func TestOptions(t *testing.T) {
 			Provide(&bytes.Buffer{}), // error, not a constructor
 			WithLogger(spy),
 		)
-		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")
+		require.Equal(t, []string{"Provide", "ProvideError"}, spy.EventTypes())
+		assert.Contains(t, spy.Events()[1].(*fxevent.ProvideError).Err.Error(), "must provide constructor function")
 	})
 }
 
@@ -504,7 +497,7 @@ func TestAppStart(t *testing.T) {
 		assert.Contains(t, err.Error(), "context deadline exceeded")
 	})
 
-	t.Run("StartError", func(t *testing.T) {
+	t.Run("Rollback", func(t *testing.T) {
 		failStart := func(lc Lifecycle) struct{} {
 			lc.Append(Hook{OnStart: func(context.Context) error {
 				return errors.New("OnStart fail")
@@ -558,14 +551,13 @@ func TestAppStart(t *testing.T) {
 		// testing.tRunner
 		//         /.../go/1.13.3/libexec/src/testing/testing.go:909
 		// Failed: can't invoke non-function {} (type struct {})
-		output := spy.String()
-		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, "can't invoke non-function")
+		require.Equal(t,
+			[]string{"Provide", "Provide", "Provide", "Invoke", "InvokeError"},
+			spy.EventTypes())
+		failedEvent := spy.Events()[len(spy.EventTypes())-1].(*fxevent.InvokeError)
+		assert.Contains(t, failedEvent.Err.Error(), "can't invoke non-function")
+		assert.Contains(t, failedEvent.Stacktrace, "go.uber.org/fx_test.TestAppStart")
+		assert.Contains(t, failedEvent.Stacktrace, "fx/app_test.go")
 	})
 
 	t.Run("ProvidingAProvideShouldFail", func(t *testing.T) {
@@ -765,7 +757,6 @@ func TestValidateApp(t *testing.T) {
 		)
 		require.NoError(t, err, "fx.ValidateApp should not return an error")
 	})
-
 }
 
 func TestDone(t *testing.T) {
@@ -782,7 +773,7 @@ func TestReplaceLogger(t *testing.T) {
 	spy := new(fxlog.Spy)
 	app := fxtest.New(t, WithLogger(spy))
 	app.RequireStart().RequireStop()
-	assert.Contains(t, spy.String(), "running")
+	assert.Equal(t, []string{"Provide", "Provide", "Provide", "Running"}, spy.EventTypes())
 }
 
 func TestNopLogger(t *testing.T) {
@@ -960,8 +951,8 @@ 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) LogEvent(event fxevent.Event) {
+	l.t.Logf("emitted event %#v", event)
 }
 
 func (l testLogger) String() string {
diff --git a/fxevent/event_test.go b/fxevent/event_test.go
new file mode 100644
index 0000000..93bbb48
--- /dev/null
+++ b/fxevent/event_test.go
@@ -0,0 +1,48 @@
+// 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 (
+	"testing"
+)
+
+// TestForCoverage adds coverage for own sake.
+func TestForCoverage(t *testing.T) {
+	events := []Event{
+		&LifecycleHookStart{},
+		&LifecycleHookStop{},
+		&ProvideError{},
+		&Supply{},
+		&Provide{},
+		&Invoke{},
+		&InvokeError{},
+		&StartError{},
+		&StopSignal{},
+		&StopError{},
+		&Rollback{},
+		&RollbackError{},
+		&Running{},
+	}
+
+	for _, e := range events {
+		e.event()
+	}
+}
diff --git a/fxevent/zap_test.go b/fxevent/zap_test.go
new file mode 100644
index 0000000..2002646
--- /dev/null
+++ b/fxevent/zap_test.go
@@ -0,0 +1,150 @@
+// 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"
+	"fmt"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/zap/zaptest"
+)
+
+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 (t *testLogSpy) AssertMessages(msgs ...string) {
+	assert.Equal(t.TB, msgs, t.Messages, "logged messages did not match")
+}
+
+func (t *testLogSpy) Reset() {
+	t.Messages = t.Messages[:0]
+}
+
+func TestZapLogger(t *testing.T) {
+	t.Parallel()
+
+	ts := newTestLogSpy(t)
+	logger := zaptest.NewLogger(ts)
+	zapLogger := ZapLogger{Logger: logger}
+
+	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\"}")
+	})
+
+	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})
+		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"),
+		})
+		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_test.go b/fxtest/app_test.go
index 63ac327..e99a97f 100644
--- a/fxtest/app_test.go
+++ b/fxtest/app_test.go
@@ -53,7 +53,7 @@ func TestApp(t *testing.T) {
 		assert.Contains(t, spy.errors.String(), "New failed", "Expected to write error to TB")
 	})
 
-	t.Run("StartFailure", func(t *testing.T) {
+	t.Run("StartError", func(t *testing.T) {
 		spy := newTB()
 
 		New(
diff --git a/fxtest/lifecycle_test.go b/fxtest/lifecycle_test.go
index ed7748a..1e22b84 100644
--- a/fxtest/lifecycle_test.go
+++ b/fxtest/lifecycle_test.go
@@ -51,7 +51,7 @@ func TestLifecycle(t *testing.T) {
 		assert.Equal(t, 2, n, "Didn't run start and stop hooks.")
 	})
 
-	t.Run("StartFailure", func(t *testing.T) {
+	t.Run("StartError", func(t *testing.T) {
 		spy := newTB()
 		lc := NewLifecycle(spy)
 		lc.Append(fx.Hook{OnStart: func(context.Context) error { return errors.New("fail") }})
diff --git a/internal/fxlog/fxlog_test.go b/internal/fxlog/fxlog_test.go
index def15aa..91aed05 100644
--- a/internal/fxlog/fxlog_test.go
+++ b/internal/fxlog/fxlog_test.go
@@ -21,18 +21,10 @@
 package fxlog
 
 import (
-	"bytes"
-	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"go.uber.org/dig"
-	"go.uber.org/fx/internal/fxlog/foovendor"
-	"go.uber.org/fx/internal/fxlog/sample.git"
-	"go.uber.org/fx/internal/fxreflect"
 	"go.uber.org/fx/internal/testutil"
-	"go.uber.org/zap"
-	"go.uber.org/zap/zapcore"
 )
 
 func TestNew(t *testing.T) {
@@ -40,199 +32,3 @@ func TestNew(t *testing.T) {
 		DefaultLogger(testutil.WriteSyncer{T: t})
 	})
 }
-
-func TestPrint(t *testing.T) {
-	sink := new(Spy)
-
-	t.Run("printf", func(t *testing.T) {
-		sink.Reset()
-		Info("foo 42").Write(sink)
-		assert.Equal(t, "foo 42\n", sink.String())
-	})
-
-	t.Run("printProvide", func(t *testing.T) {
-		sink.Reset()
-		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()
-		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) {
-		sink.Reset()
-
-		type A struct{}
-		type B struct{}
-		type C struct{}
-		type Ret struct {
-			dig.Out
-			*A
-			B
-			C `name:"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()
-		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(), "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) {
-		sink.Reset()
-
-		type A struct{}
-		type B struct{}
-		type Ret struct {
-			dig.Out
-			*B `name:"foo"`
-
-			A1 *A `name:"primary"`
-			A2 *A `name:"secondary"`
-		}
-		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.
-		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
-		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
-		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()
-		sig := os.Interrupt.String()
-		Info(sig).Write(sink)
-		assert.Equal(t, "interrupt\n", sink.String())
-	})
-}
diff --git a/internal/fxlog/spy_test.go b/internal/fxlog/spy_test.go
index 7bda0c9..164605b 100644
--- a/internal/fxlog/spy_test.go
+++ b/internal/fxlog/spy_test.go
@@ -21,71 +21,39 @@
 package fxlog
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"go.uber.org/fx/fxevent"
 )
 
 func TestSpy(t *testing.T) {
 	var s Spy
 
 	t.Run("empty spy", func(t *testing.T) {
-		assert.Empty(t, s.Messages(), "messages must be empty")
-		assert.Empty(t, s.String(), "string must be empty")
+		assert.Empty(t, s.Events(), "events must be empty")
+		assert.Empty(t, s.EventTypes(), "event types must be empty")
 	})
 
-	s.Log(Entry{
-		Message: "foo bar",
-	})
-	t.Run("unformatted message", func(t *testing.T) {
-		assert.Equal(t, []Entry{
-			{Message: "foo bar"},
-		}, s.Messages(), "messages must match")
-		assert.Equal(t, "foo bar\n", s.String(), "string must match")
+	s.LogEvent(&fxevent.Running{})
+	t.Run("use after reset", func(t *testing.T) {
+		assert.Equal(t, "Running", s.EventTypes()[0])
 	})
 
-	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, []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\terror: great sadness\n", s.String())
+	s.LogEvent(&fxevent.ProvideError{Err: fmt.Errorf("some error")})
+	t.Run("some error", func(t *testing.T) {
+		assert.Equal(t, "ProvideError", s.EventTypes()[1])
 	})
 
 	s.Reset()
 	t.Run("reset", func(t *testing.T) {
-		assert.Empty(t, s.Messages(), "messages must be empty")
-		assert.Empty(t, s.String(), "string must be empty")
+		assert.Empty(t, s.Events(), "events must be empty")
+		assert.Empty(t, s.EventTypes(), "event types must be empty")
 	})
 
-	s.Log(Entry{
-		Message: "baz qux",
-	})
+	s.LogEvent(&fxevent.Running{})
 	t.Run("use after reset", func(t *testing.T) {
-		assert.Equal(t, []Entry{
-			{
-				Message: "baz qux",
-			},
-		}, s.Messages(), "messages must match")
-		assert.Equal(t, "baz qux\n", s.String(), "string must match")
+		assert.Equal(t, "Running", s.EventTypes()[0])
 	})
 }
diff --git a/internal/lifecycle/lifecycle_test.go b/internal/lifecycle/lifecycle_test.go
index 9bfcb65..b5354fc 100644
--- a/internal/lifecycle/lifecycle_test.go
+++ b/internal/lifecycle/lifecycle_test.go
@@ -26,12 +26,13 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"go.uber.org/fx/fxevent"
 	"go.uber.org/fx/internal/fxlog"
 	"go.uber.org/fx/internal/testutil"
 	"go.uber.org/multierr"
 )
 
-func testLogger(t *testing.T) fxlog.Logger {
+func testLogger(t *testing.T) fxevent.Logger {
 	return fxlog.DefaultLogger(testutil.WriteSyncer{T: t})
 }
 
diff --git a/opt_test.go b/opt_test.go
index dd56b37..f57d0fc 100644
--- a/opt_test.go
+++ b/opt_test.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
@@ -20,9 +20,9 @@
 
 package fx
 
-import "go.uber.org/fx/internal/fxlog"
+import "go.uber.org/fx/fxevent"
 
 // WithLogger exposes logger option for tests.
-func WithLogger(l fxlog.Logger) Option {
+func WithLogger(l fxevent.Logger) Option {
 	return withLogger(l)
 }