Skip to content

Commit ab36659

Browse files
belimawrmauri870
andauthored
[logp/logptest] Add logptest.Logger (#362)
This commit adds logptest.Logger, a logger meant to be used on tests, its key features: - All logs are saved on a single temporary log file - On failures, the log file is kept and its path printed - Methods to search and wait for log entries are provided, they keep track of the offset, ensuring ordering when when searching for logs --------- Co-authored-by: Mauri de Souza Meneguzzo <[email protected]>
1 parent 1748301 commit ab36659

File tree

3 files changed

+429
-2
lines changed

3 files changed

+429
-2
lines changed

logp/logptest/logger.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package logptest
19+
20+
import (
21+
"bufio"
22+
"errors"
23+
"fmt"
24+
"io"
25+
"os"
26+
"strings"
27+
"testing"
28+
"time"
29+
30+
"github.com/stretchr/testify/assert"
31+
"github.com/stretchr/testify/require"
32+
"go.elastic.co/ecszap"
33+
"go.uber.org/zap"
34+
"go.uber.org/zap/zapcore"
35+
36+
"github.com/elastic/elastic-agent-libs/logp"
37+
)
38+
39+
// Logger wraps a *logp.Logger and makes it more suitable for tests.
40+
// Key features:
41+
// - All logs are saved on a single temporary log file
42+
// - On failures, the log file is kept and its path printed
43+
// - Methods to search and wait for log entries are provided,
44+
// they keep track of the offset, ensuring ordering when
45+
// when searching for logs
46+
type Logger struct {
47+
*logp.Logger
48+
logFile *os.File
49+
offset int64
50+
}
51+
52+
// NewFileLogger returns a logger that logs to a file and has methods
53+
// to search in the logs.
54+
// If dir is not an empty string, the log file will be generated on
55+
// this folder. This is useful to make CI collect the logs in case
56+
// a test fails. If dir is an empty string, the OS temporary folder
57+
// is used.
58+
//
59+
// The *logp.Logger is embedded into it, so [Logger] is a drop-in
60+
// replacement for a *logp.Logger, or the logger can be accessed via
61+
// [Logger.Logger]
62+
func NewFileLogger(t testing.TB, dir string) *Logger {
63+
encoderConfig := ecszap.ECSCompatibleEncoderConfig(zapcore.EncoderConfig{})
64+
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
65+
encoder := zapcore.NewJSONEncoder(encoderConfig)
66+
67+
if dir == "" {
68+
dir = os.TempDir()
69+
}
70+
71+
if err := os.MkdirAll(dir, 0o750); err != nil {
72+
t.Fatalf("cannot create folder for logs: %s", err)
73+
}
74+
75+
f, err := os.CreateTemp(dir, "testing-logger-*.log")
76+
if err != nil {
77+
t.Fatalf("cannot create log file: %s", err)
78+
}
79+
80+
core := zapcore.NewCore(encoder, zapcore.AddSync(f), zap.DebugLevel)
81+
82+
tl := &Logger{
83+
logFile: f,
84+
}
85+
86+
t.Cleanup(func() {
87+
// Sync the core, the file, then close the file
88+
if err := core.Sync(); err != nil {
89+
t.Logf("cannot sync zap core: %s", err)
90+
}
91+
92+
if err := f.Sync(); err != nil {
93+
t.Logf("cannot sync log file: %s", err)
94+
}
95+
96+
if err := f.Close(); err != nil {
97+
t.Logf("cannot close log file: %s", err)
98+
}
99+
100+
// If the test failed, print the log file location,
101+
// otherwise remove it.
102+
if t.Failed() {
103+
t.Logf("Full logs written to %s", f.Name())
104+
return
105+
}
106+
107+
if err := os.Remove(f.Name()); err != nil {
108+
t.Logf("could not remove temporary log file: %s", err)
109+
}
110+
})
111+
112+
logger := logp.NewLogger(
113+
"",
114+
zap.WrapCore(func(in zapcore.Core) zapcore.Core {
115+
return core
116+
}))
117+
118+
tl.Logger = logger
119+
120+
return tl
121+
}
122+
123+
// WaitLogsContains waits for the specified string s to be present in the logs within
124+
// the given timeout duration and fails the test if s is not found.
125+
// It keeps track of the log file offset, reading only new lines. Each
126+
// subsequent call to WaitLogsContains will only check logs not yet evaluated.
127+
// msgAndArgs should be a format string and arguments that will be printed
128+
// if the logs are not found, providing additional context for debugging.
129+
func (l *Logger) WaitLogsContains(t testing.TB, s string, timeout time.Duration, msgAndArgs ...any) {
130+
t.Helper()
131+
require.EventuallyWithT(
132+
t,
133+
func(c *assert.CollectT) {
134+
found, err := l.FindInLogs(s)
135+
if err != nil {
136+
c.Errorf("cannot check the log file: %s", err)
137+
return
138+
}
139+
140+
if !found {
141+
c.Errorf("did not find '%s' in the logs", s)
142+
}
143+
},
144+
timeout,
145+
100*time.Millisecond,
146+
msgAndArgs...)
147+
}
148+
149+
// LogContains searches for str in the log file keeping track of the
150+
// offset. If there is any issue reading the log file, then t.Fatalf is called,
151+
// if str is not present in the logs, t.Errorf is called.
152+
func (l *Logger) LogContains(t testing.TB, str string) {
153+
t.Helper()
154+
found, err := l.FindInLogs(str)
155+
if err != nil {
156+
t.Fatalf("cannot read log file: %s", err)
157+
}
158+
159+
if !found {
160+
t.Errorf("'%s' not found in logs", str)
161+
}
162+
}
163+
164+
// FindInLogs searches for str in the log file keeping track of the offset.
165+
// It returns true if str is found in the logs. If there are any errors,
166+
// it returns false and the error
167+
func (l *Logger) FindInLogs(str string) (bool, error) {
168+
// Open the file again so we can seek and not interfere with
169+
// the logger writing to it.
170+
f, err := os.Open(l.logFile.Name())
171+
if err != nil {
172+
return false, fmt.Errorf("cannot open log file for reading: %w", err)
173+
}
174+
175+
if _, err := f.Seek(l.offset, io.SeekStart); err != nil {
176+
return false, fmt.Errorf("cannot seek log file: %w", err)
177+
}
178+
179+
r := bufio.NewReader(f)
180+
for {
181+
data, err := r.ReadBytes('\n')
182+
line := string(data)
183+
l.offset += int64(len(data))
184+
185+
if err != nil {
186+
if !errors.Is(err, io.EOF) {
187+
return false, fmt.Errorf("error reading log file '%s': %w", l.logFile.Name(), err)
188+
}
189+
break
190+
}
191+
192+
if strings.Contains(line, str) {
193+
return true, nil
194+
}
195+
}
196+
197+
return false, nil
198+
}
199+
200+
// ResetOffset resets the log file offset
201+
func (l *Logger) ResetOffset() {
202+
l.offset = 0
203+
}

0 commit comments

Comments
 (0)