Skip to content

Commit 04dcaf3

Browse files
Add timestamp to console logs (#4344)
It looks like we dropped this out of the zap logs, way back in #69. @emcfarlane correctly ported the lack of a timestamp over to slog in #3844. This probably made sense when `buf` was running fairly quick CLI commands, but now that we have various server processes running from `buf`, it makes sense to add a timestamp for the various logs being emitted. I specifically wanted this when debugging `buf lsp serve`. We _could_ just make this optional (only for certain commands), but this feels fairly uncontroversial.
1 parent bb12709 commit 04dcaf3

3 files changed

Lines changed: 56 additions & 21 deletions

File tree

private/bufpkg/bufconnect/interceptors_test.go

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import (
1919
"context"
2020
"encoding/base64"
2121
"errors"
22-
"fmt"
22+
"strings"
2323
"testing"
24+
"time"
2425

2526
"buf.build/go/app"
2627
"buf.build/go/app/appext"
@@ -54,7 +55,7 @@ func TestNewAuthorizationInterceptorProvider(t *testing.T) {
5455
return nil, errors.New("error auth token")
5556
}
5657
return nil, nil
57-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
58+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
5859
assert.NoError(t, err)
5960

6061
getMachineForName := func(app.EnvContainer, string) (netrc.Machine, error) {
@@ -67,7 +68,7 @@ func TestNewAuthorizationInterceptorProvider(t *testing.T) {
6768
return nil, errors.New("error auth token")
6869
}
6970
return nil, nil
70-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
71+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
7172
assert.NoError(t, err)
7273

7374
// testing using tokenSet over netrc tokenToAuthKey
@@ -76,7 +77,7 @@ func TestNewAuthorizationInterceptorProvider(t *testing.T) {
7677
return nil, errors.New("error auth token")
7778
}
7879
return nil, nil
79-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
80+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
8081
assert.NoError(t, err)
8182

8283
// testing using netrc tokenToAuthKey over tokenSet
@@ -85,15 +86,15 @@ func TestNewAuthorizationInterceptorProvider(t *testing.T) {
8586
return nil, errors.New("error auth token")
8687
}
8788
return nil, nil
88-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
89+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
8990
assert.NoError(t, err)
9091

9192
_, err = NewAuthorizationInterceptorProvider()("default")(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
9293
if req.Header().Get(AuthenticationHeader) != "" {
9394
return nil, errors.New("error auth token")
9495
}
9596
return nil, nil
96-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
97+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
9798
assert.NoError(t, err)
9899

99100
tokenSet, err = NewTokenProviderFromContainer(app.NewEnvContainer(map[string]string{
@@ -102,7 +103,7 @@ func TestNewAuthorizationInterceptorProvider(t *testing.T) {
102103
assert.NoError(t, err)
103104
_, err = NewAuthorizationInterceptorProvider(tokenSet)("default")(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
104105
return nil, errors.New("underlying cause")
105-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
106+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
106107
authErr, ok := AsAuthError(err)
107108
assert.True(t, ok)
108109
assert.Equal(t, TokenEnvKey, authErr.tokenEnvKey)
@@ -119,15 +120,15 @@ func TestCLIWarningInterceptor(t *testing.T) {
119120
resp := connect.NewResponse(&bytes.Buffer{})
120121
resp.Header().Set(CLIWarningHeaderName, base64.StdEncoding.EncodeToString([]byte(warningMessage)))
121122
return resp, nil
122-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
123+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
123124
assert.NoError(t, err)
124-
assert.Equal(t, fmt.Sprintf("WARN\t%s\n", warningMessage), buf.String())
125+
assertWarnLog(t, warningMessage, buf.String())
125126

126127
// testing no warning message in valid response with no header
127128
buf.Reset()
128129
_, err = NewCLIWarningInterceptor(appext.NewLoggerContainer(logger))(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
129130
return connect.NewResponse(&bytes.Buffer{}), nil
130-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
131+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
131132
assert.NoError(t, err)
132133
assert.Equal(t, "", buf.String())
133134
}
@@ -143,9 +144,9 @@ func TestCLIWarningInterceptorFromError(t *testing.T) {
143144
err := connect.NewError(connect.CodeInternal, errors.New("error"))
144145
err.Meta().Set(CLIWarningHeaderName, base64.StdEncoding.EncodeToString([]byte(warningMessage)))
145146
return nil, err
146-
})(context.Background(), connect.NewRequest(&bytes.Buffer{}))
147+
})(t.Context(), connect.NewRequest(&bytes.Buffer{}))
147148
assert.Error(t, err)
148-
assert.Equal(t, fmt.Sprintf("WARN\t%s\n", warningMessage), buf.String())
149+
assertWarnLog(t, warningMessage, buf.String())
149150
}
150151

151152
type testRequest[T any] struct {
@@ -163,12 +164,25 @@ func (r testRequest[_]) Peer() connect.Peer {
163164
}
164165
}
165166

167+
// assertWarnLog verifies that logOutput contains a single WARN log line with the
168+
// given message. The console handler prepends an RFC3339 timestamp, so this
169+
// helper parses the three tab-separated fields and checks each one.
170+
func assertWarnLog(t *testing.T, message, logOutput string) {
171+
t.Helper()
172+
parts := strings.SplitN(strings.TrimRight(logOutput, "\n"), "\t", 3)
173+
require.Len(t, parts, 3, "expected 3 tab-separated fields in log output")
174+
_, err := time.Parse(time.RFC3339, parts[0])
175+
assert.NoError(t, err, "expected valid RFC3339 timestamp")
176+
assert.Equal(t, "WARN", parts[1])
177+
assert.Equal(t, message, parts[2])
178+
}
179+
166180
func TestNewAugmentedConnectErrorInterceptor(t *testing.T) {
167181
t.Parallel()
168182
_, err := NewAugmentedConnectErrorInterceptor()(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
169183
err := connect.NewError(connect.CodeUnknown, errors.New("405 Method Not Allowed"))
170184
return nil, err
171-
})(context.Background(), testRequest[bytes.Buffer]{Request: connect.NewRequest(&bytes.Buffer{})})
185+
})(t.Context(), testRequest[bytes.Buffer]{Request: connect.NewRequest(&bytes.Buffer{})})
172186
assert.Error(t, err)
173187
var augmentedConnectError *AugmentedConnectError
174188
assert.ErrorAs(t, err, &augmentedConnectError)

private/pkg/slogapp/console.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"log/slog"
2323
"os"
2424
"sync"
25+
"time"
2526

2627
"github.com/mattn/go-colorable"
2728
"golang.org/x/term"
@@ -132,6 +133,10 @@ func (c *consoleHandler) Handle(ctx context.Context, r slog.Record) error {
132133
c.lock.Lock()
133134
defer c.lock.Unlock()
134135
c.buffer.Reset()
136+
if !r.Time.IsZero() {
137+
c.buffer.WriteString(r.Time.Format(time.RFC3339))
138+
c.buffer.WriteString(consoleSeparator)
139+
}
135140
if c.enableColor {
136141
c.buffer.WriteString(colorize(r.Level.String(), getColor(r.Level)))
137142
} else {

private/pkg/slogapp/console_test.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"os"
2323
"strings"
2424
"testing"
25+
"time"
2526

2627
"github.com/stretchr/testify/assert"
2728
"github.com/stretchr/testify/require"
@@ -44,7 +45,7 @@ func TestIsWriterTTY(t *testing.T) {
4445
func TestConsoleLogOutput(t *testing.T) {
4546
t.Parallel()
4647

47-
testConsolLogOutput(t, func(logger *slog.Logger) {
48+
testConsoleLogOutput(t, func(logger *slog.Logger) {
4849
logger.Info("hello", slog.String("a", "b"))
4950
logger.Info("hello world")
5051
}, []map[string]any{{
@@ -56,7 +57,7 @@ func TestConsoleLogOutput(t *testing.T) {
5657
slog.MessageKey: "hello world",
5758
}}, withConsoleColor(true))
5859

59-
testConsolLogOutput(t, func(logger *slog.Logger) {
60+
testConsoleLogOutput(t, func(logger *slog.Logger) {
6061
logger.Info("info", slog.String("a", "b"))
6162
logger.Error("error")
6263
}, []map[string]any{{
@@ -68,7 +69,7 @@ func TestConsoleLogOutput(t *testing.T) {
6869
slog.MessageKey: "error",
6970
}})
7071

71-
testConsolLogOutput(t, func(logger *slog.Logger) {
72+
testConsoleLogOutput(t, func(logger *slog.Logger) {
7273
logger = logger.With(slog.String("a", "b"))
7374
logger = logger.WithGroup("g")
7475
logger.Error("error message", slog.String("c", "d"))
@@ -87,7 +88,7 @@ func TestConsoleLogOutput(t *testing.T) {
8788
"a": "b",
8889
}}, withConsoleColor(true))
8990

90-
testConsolLogOutput(t, func(logger *slog.Logger) {
91+
testConsoleLogOutput(t, func(logger *slog.Logger) {
9192
logger.Info("key spaces", slog.String("a key", "with spaces"))
9293
}, []map[string]any{{
9394
slog.LevelKey: colorize("INFO", getColor(slog.LevelInfo)),
@@ -96,7 +97,7 @@ func TestConsoleLogOutput(t *testing.T) {
9697
}}, withConsoleColor(true))
9798
}
9899

99-
func testConsolLogOutput(t *testing.T, run func(logger *slog.Logger), expects []map[string]any, options ...consoleHandlerOption) {
100+
func testConsoleLogOutput(t *testing.T, run func(logger *slog.Logger), expects []map[string]any, options ...consoleHandlerOption) {
100101
t.Helper()
101102
var buf bytes.Buffer
102103
consoleHandler := newConsoleHandler(&buf, slog.LevelInfo, options...)
@@ -118,16 +119,31 @@ func testConsolLogOutput(t *testing.T, run func(logger *slog.Logger), expects []
118119
require.Equal(t, len(expects), len(outputs))
119120
for i := range len(outputs) {
120121
output, expect := outputs[i], expects[i]
122+
// Verify the timestamp is present and parseable, then remove it before
123+
// comparing the rest of the output (timestamps are dynamic).
124+
ts, ok := output[slog.TimeKey]
125+
if assert.True(t, ok, "expected timestamp in log output") {
126+
timestamp, ok := ts.(string)
127+
assert.True(t, ok, "expected timestamp to be string type in log output")
128+
_, err := time.Parse(time.RFC3339, timestamp)
129+
assert.NoError(t, err, "timestamp should be valid RFC3339")
130+
}
131+
delete(output, slog.TimeKey)
121132
assert.Equal(t, expect, output)
122133
}
123134
}
124135

125-
// testParseLogLine passes the output of a single log line.
136+
// testParseLogLine parses the output of a single log line.
137+
// The format is: TIME\tLEVEL\tMESSAGE\t{...JSON attrs...}\n
126138
func testParseLogLine(lineBytes []byte) (map[string]any, error) {
127139
top := map[string]any{}
128140
line := string(bytes.TrimSpace(lineBytes))
129-
index, line, _ := strings.Cut(line, consoleSeparator)
130-
top[slog.LevelKey] = index
141+
// First field is the timestamp.
142+
timestamp, line, _ := strings.Cut(line, consoleSeparator)
143+
top[slog.TimeKey] = timestamp
144+
// Second field is the level.
145+
level, line, _ := strings.Cut(line, consoleSeparator)
146+
top[slog.LevelKey] = level
131147
if len(line) == 0 {
132148
return top, nil
133149
}

0 commit comments

Comments
 (0)