diff --git a/log/api.go b/log/api.go index f8d5213..dc2a48d 100644 --- a/log/api.go +++ b/log/api.go @@ -31,8 +31,7 @@ func Errorf(ctx context.Context, err error, format string, args ...interface{}) // From returns a copied logger from the context that you can use to access logger API. func From(ctx context.Context) Logger { - f := getFields(ctx) - return f.configureLogger(ctx, f.getCopiedLogger().(fieldSetter)) + return from(ctx, getFields(ctx)) } // Info logs from context at the debug level. @@ -45,6 +44,21 @@ func Infof(ctx context.Context, format string, args ...interface{}) { Fields{}.Infof(ctx, format, args...) } +// RegisterListener registers callbacks that are called after a log. +func RegisterListener(ctx context.Context, callback func(context.Context, Fields)) context.Context{ + cbList, isList := ctx.Value(listenerKey{}).([]func(context.Context, Fields)) + if !isList { + cbList = []func(context.Context, Fields){callback} + } else { + cbList = append(cbList, callback) + } + return context.WithValue( + context.WithValue(ctx, canonicalFieldsKey{}, frozen.NewMapBuilder(0)), + listenerKey{}, + cbList, + ) +} + // Suppress will ensure that suppressed keys are not logged. func Suppress(keys ...string) Fields { return Fields{}.Suppress(keys...) @@ -162,9 +176,8 @@ func (f Fields) WithLogger(logger Logger) Fields { // String returns a string that represent the current fields func (f Fields) String(ctx context.Context) string { - fields := &fieldsCollector{} - f.configureLogger(ctx, fields) - return fields.fields.String() + m, _ := f.getResolvedFields(ctx) + return m.String() } // MergedString returns a string that represents the current fields merged by fields in context diff --git a/log/canonical.go b/log/canonical.go new file mode 100644 index 0000000..c1808b3 --- /dev/null +++ b/log/canonical.go @@ -0,0 +1,26 @@ +package log + +import ( + "context" + + "github.com/arr-ai/frozen" +) + +// OnLog is a callback that is called when fields and configurations are added to the logger. +func OnLog(ctx context.Context, fields Fields) { + if fields := ctx.Value(canonicalFieldsKey{}); fields == nil { + Info(ctx, "Canonical fields have not been set up properly, fields will not be collected") + return + } + addCanonicalFields(getCanonicalFields(ctx), fields) +} + +// CanonicalLog logs all the collected fields from previous logs in non-verbose mode. +func CanonicalLog(ctx context.Context) { + from(ctx, Fields{getCanonicalFields(ctx).Finish()}).Info() +} + +// WithCanonicalLog creates a canonical fields collector. +func WithCanonicalLog(ctx context.Context) context.Context { + return context.WithValue(ctx, canonicalFieldsKey{}, frozen.NewMapBuilder(0)) +} diff --git a/log/canonical_test.go b/log/canonical_test.go new file mode 100644 index 0000000..94799f7 --- /dev/null +++ b/log/canonical_test.go @@ -0,0 +1,62 @@ +package log + +import ( + "context" + "testing" + + "github.com/alecthomas/assert" + "github.com/arr-ai/frozen" +) + +func TestCanonicalLog(t *testing.T) { + t.Parallel() + + ctx := WithCanonicalLog(context.Background()) + logger := newMockLogger() + + mb := getCanonicalFields(ctx) + + addValues(mb, logger) + setLogMockAssertion(logger, mb.Finish().Without(frozen.NewSet(loggerKey{}))) + logger.On("Info") + + // add the same values again because Finish would remove the values from the map builder. + addValues(mb, logger) + CanonicalLog(ctx) + + logger.AssertExpectations(t) +} + +func TestOnLogWithoutCanonicalFieldsSetup(t *testing.T) { + t.Parallel() + + logger := newMockLogger() + logger.On("Info", "Canonical fields have not been set up properly, fields will not be collected") + setLogMockAssertion(logger, frozen.NewMap()) + OnLog(WithLogger(logger).Onto(context.Background()), Fields{frozen.NewMap()}) + logger.AssertExpectations(t) +} + +func TestOnLog(t *testing.T) { + t.Parallel() + + ctx := WithCanonicalLog(context.Background()) + mb := getCanonicalFields(ctx) + addValues(mb, newMockLogger()) + + testField := frozen.NewMap().With("additional", "value").With("1", 1).With("byte", 'l') + OnLog(ctx, Fields{testField}) + + m := mb.Finish() + for i := testField.Range(); i.Next(); { + val, exists := m.Get(i.Key()) + assert.True(t, exists && val == i.Value()) + } +} + +func addValues(mb *frozen.MapBuilder, logger Logger) { + mb.Put("test", 1) + mb.Put("test2", 'k') + mb.Put("test3", "string value") + mb.Put(loggerKey{}, logger) +} \ No newline at end of file diff --git a/log/util.go b/log/util.go index a2cac97..303da26 100644 --- a/log/util.go +++ b/log/util.go @@ -9,6 +9,8 @@ import ( const errMsgKey = "error_message" type fieldsContextKey struct{} +type canonicalFieldsKey struct{} +type listenerKey struct {} type loggerKey struct{} type suppress struct{} type ctxRef struct{ ctxKey interface{} } @@ -30,10 +32,26 @@ func (f Fields) getCopiedLogger() Logger { return logger.(copyable).Copy() } -func (f Fields) configureLogger(ctx context.Context, logger fieldSetter) Logger { +func configureLogger(logger fieldSetter, fields frozen.Map, configs []Config) Logger { + return applyConfiguration(logger.(Logger), configs).(fieldSetter).PutFields(fields) +} + +func applyConfiguration(logger Logger, configs []Config) Logger { + for _, c := range configs { + err := c.Apply(logger.(Logger)) + if err != nil { + //TODO: should decide whether it should panic or not + panic(err) + } + } + return logger +} + +func (f Fields) getResolvedFields(ctx context.Context) (frozen.Map, []Config) { fields := f.m var toSuppress frozen.SetBuilder toSuppress.Add(loggerKey{}) + configs := make([]Config, 0) for i := fields.Range(); i.Next(); { switch k := i.Value().(type) { case ctxRef: @@ -52,20 +70,58 @@ func (f Fields) configureLogger(ctx context.Context, logger fieldSetter) Logger toSuppress.Add(i.Key()) case Config: toSuppress.Add(i.Key()) - err := k.Apply(logger.(Logger)) - if err != nil { - //TODO: should decide whether it should panic or not - panic(err) - } + configs = append(configs, k) } } - return logger.PutFields(fields.Without(toSuppress.Finish())) + return fields.Without(toSuppress.Finish()), configs } func (f Fields) with(key, val interface{}) Fields { return Fields{f.m.With(key, val)} } +func getCanonicalFields(ctx context.Context) *frozen.MapBuilder { + mb, exists := ctx.Value(canonicalFieldsKey{}).(*frozen.MapBuilder) + if !exists { + return frozen.NewMapBuilder(0) + } + return mb +} + +func addCanonicalFields(mb *frozen.MapBuilder, fields Fields) { + for i := fields.m.Range(); i.Next(); { + switch k := i.Key().(type) { + case Config, Logger: + mb.Put(k, i.Value()) + default: + if f, exists := mb.Get(k); exists { + if val, isList := f.([]interface{}); isList { + mb.Put(k, append(val, i.Value())) + } else { + mb.Put(k, []interface{}{f, i.Value()}) + } + } else { + mb.Put(k, i.Value()) + } + } + } +} + +func doCallbacks(ctx context.Context, fields Fields) { + callbacks := ctx.Value(listenerKey{}) + if callbacks != nil { + for _, c := range callbacks.([]func(context.Context, Fields)) { + c(ctx, fields) + } + } +} + +func from(ctx context.Context, f Fields) Logger { + fields, configs := f.getResolvedFields(ctx) + doCallbacks(ctx, Fields{fields}) + return configureLogger(f.getCopiedLogger().(fieldSetter), fields, configs) +} + func getFields(ctx context.Context) Fields { fields, exists := ctx.Value(fieldsContextKey{}).(frozen.Map) if !exists { diff --git a/log/util_test.go b/log/util_test.go index 24b3798..550bd24 100644 --- a/log/util_test.go +++ b/log/util_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/arr-ai/frozen" + "github.com/stretchr/testify/assert" ) type key1 struct{} @@ -75,7 +76,7 @@ func TestConfigureLogger(t *testing.T) { for i := c.contextFields.Range(); i.Next(); { ctx = context.WithValue(ctx, i.Key(), i.Value()) } - Fields{c.unresolveds}.configureLogger(ctx, Logger(logger).(fieldSetter)) + logger = configureLogger(Logger(logger).(fieldSetter), c.expected, []Config{}).(*mockLogger) logger.AssertExpectations(t) }) } @@ -86,7 +87,7 @@ func TestConfigureLoggerWithConfigs(t *testing.T) { //TODO: add more configs testCase := getUnresolvedFieldsCases()[0] - unresolveds := Fields{testCase.unresolveds}.WithConfigs(NewJSONFormat()) + unresolved := Fields{testCase.unresolveds}.WithConfigs(NewJSONFormat()) expected := testCase.expected logger := newMockLogger() @@ -97,7 +98,24 @@ func TestConfigureLoggerWithConfigs(t *testing.T) { for i := testCase.contextFields.Range(); i.Next(); { ctx = context.WithValue(ctx, i.Key(), i.Value()) } - - unresolveds.configureLogger(ctx, Logger(logger).(fieldSetter)) + resolved, configs := unresolved.getResolvedFields(ctx) + configureLogger(Logger(logger).(fieldSetter), resolved, configs) logger.AssertExpectations(t) } + +func TestRegisterListener(t *testing.T) { + t.Parallel() + + mockListener1 := OnLog + + ctx := RegisterListener(context.Background(), mockListener1) + cbs, isCallbackList := ctx.Value(listenerKey{}).([]func(context.Context, Fields)) + assert.True(t, isCallbackList) + assert.Equal(t, 1, len(cbs)) + + mockListener2 := func(context.Context, Fields){} + ctx = RegisterListener(ctx, mockListener2) + cbs, isCallbackList = ctx.Value(listenerKey{}).([]func(context.Context, Fields)) + assert.True(t, isCallbackList) + assert.Equal(t, 2, len(cbs)) +}