Skip to content

Commit fba2bcf

Browse files
committed
prune-junit-xml: simplify failure message
In Go unit tests, the entire test output becomes the failure message because `go test` doesn't track why a test fails. This can make the failure message pretty large, in particular in integration tests. We cannot identify the real failure either because Kubernetes has no convention for how to format test failures. What we can do is recognize log output added by klog. prune-junit-xml now moves the full text to to the test output and only keep those lines in the failure which are not from klog. The klog output parsing might eventually get moved to k8s.io/logtools/logparse. For now it is developed as a sub-package of prune-junit-xml.
1 parent c9a61af commit fba2bcf

File tree

5 files changed

+480
-16
lines changed

5 files changed

+480
-16
lines changed
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package logparse provides a parser for the klog text format:
18+
//
19+
// I1007 13:16:55.727802 1146763 example.go:57] "Key/value encoding" logger="example" foo="bar" duration="1s" int=42 float=3.14 string="hello world" quotedString="hello \"world\"" multiLinString=<
20+
// hello
21+
// world
22+
// >
23+
// E1007 15:20:04.343375 1157755 example.go:41] Log using Errorf, err: fail
24+
//
25+
// It also supports the klog/ktesting unit test log output:
26+
//
27+
// === RUN TestKlogr
28+
// example_test.go:45: I1007 13:28:21.908998] hello world
29+
// example_test.go:46: E1007 13:28:21.909034] failed err="failed: some error"
30+
// example_test.go:47: I1007 13:28:21.909042] verbosity 1
31+
// example_test.go:48: I1007 13:28:21.909047] main/helper: with prefix
32+
// example_test.go:50: I1007 13:28:21.909076] key/value pairs int=1 float=2 pair="(1, 2)" raw={"Name":"joe","NS":"kube-system"} slice=[1,2,3,"str"] map={"a":1,"b":2} kobj="kube-system/joe" string="hello world" quotedString="hello \"world\"" multiLineString=<
33+
// hello
34+
// world
35+
// >
36+
// example_test.go:63: I1007 13:28:21.909085] info message level 4
37+
// example_test.go:64: I1007 13:28:21.909089] info message level 5
38+
// --- PASS: TestKlogr (0.00s)
39+
// PASS
40+
// ok k8s.io/klog/v2/ktesting/example (cached)
41+
//
42+
// Arbitrary indention with space or tab is supported. All lines of
43+
// a klog log entry must be indented the same way.
44+
package logparse
45+
46+
import (
47+
"bufio"
48+
"errors"
49+
"fmt"
50+
"io"
51+
"iter"
52+
"regexp"
53+
"strings"
54+
)
55+
56+
// Parse splits log output provided by the reader into individual log entries.
57+
// The original log can be reconstructed verbatim by concatenating these
58+
// entries. If the reader fails with an error, the last log entry will
59+
// capture that error.
60+
func Parse(in io.Reader) []Entry {
61+
var log []Entry
62+
for entry := range All(in) {
63+
log = append(log, entry)
64+
}
65+
return log
66+
}
67+
68+
// All is like Parse except that it can be used in a for/range loop:
69+
//
70+
// for entry := range logparse.All(reader) {
71+
// // entry is the next log entry.
72+
// }
73+
func All(in io.Reader) iter.Seq[Entry] {
74+
return func(yield func(Entry) bool) {
75+
parse(in, yield)
76+
}
77+
}
78+
79+
// Entry is the base type for a log entry.
80+
//
81+
// Use type assertions to check for additional information. All
82+
// of the instances behind this interface are pointers.
83+
type Entry interface {
84+
// LogData returns a verbatim copy of the original log chunk,
85+
// including one or more line breaks. Concatenating these chunks
86+
// from all entries will reconstruct the parsed log.
87+
LogData() string
88+
}
89+
90+
// ErrorEntry captures the error encountered while reading the log input.
91+
type ErrorEntry struct {
92+
Err error
93+
}
94+
95+
var _ Entry = &ErrorEntry{}
96+
var _ fmt.Stringer = &ErrorEntry{}
97+
98+
func (e *ErrorEntry) LogData() string { return "" }
99+
func (e *ErrorEntry) String() string { return e.Err.Error() }
100+
101+
// OtherEntry captures some log line which is not part of a klog log entry.
102+
type OtherEntry struct {
103+
Data string
104+
}
105+
106+
var _ Entry = &OtherEntry{}
107+
var _ fmt.Stringer = &OtherEntry{}
108+
109+
func (e *OtherEntry) LogData() string { return e.Data }
110+
func (e *OtherEntry) String() string { return e.Data }
111+
112+
// LogEntry captures some log entry which was recognized as coming from klog.
113+
type KlogEntry struct {
114+
Data string
115+
Severity Severity
116+
}
117+
118+
type Severity byte
119+
120+
const (
121+
SeverityInfo = Severity('I')
122+
SeverityWarning = Severity('W')
123+
SeverityError = Severity('E')
124+
SeverityFatal = Severity('F')
125+
)
126+
127+
var _ Entry = &KlogEntry{}
128+
var _ fmt.Stringer = &KlogEntry{}
129+
130+
func (e *KlogEntry) LogData() string { return e.Data }
131+
func (e *KlogEntry) String() string { return e.Data }
132+
133+
func parse(in io.Reader, yield func(Entry) bool) {
134+
// Read lines using arbitrary length, which can't be done with a
135+
// bufio.Scanner.
136+
reader := bufio.NewReader(in)
137+
line, err := reader.ReadString('\n')
138+
for {
139+
// First deliver the current line. This may need to look
140+
// ahead and thus returns the next line.
141+
var nextLine string
142+
var nextErr error
143+
if len(line) > 0 {
144+
var cont bool
145+
nextLine, cont, nextErr = parseLine(reader, line, yield)
146+
if !cont {
147+
return
148+
}
149+
} else {
150+
nextLine, nextErr = reader.ReadString('\n')
151+
}
152+
153+
// Finalize parsing?
154+
switch {
155+
case err == nil:
156+
// Okay.
157+
case errors.Is(err, io.EOF):
158+
return
159+
default:
160+
yield(&ErrorEntry{Err: err})
161+
return
162+
}
163+
164+
line = nextLine
165+
err = nextErr
166+
}
167+
}
168+
169+
const (
170+
pid = `(?<pid>[[:digit:]]+)`
171+
source = `(?<source>[^:]+:[[:digit:]]+)`
172+
severity = `(?<severity>[IWEF])`
173+
datetime = `(?<month>[[:digit:]]{2})(?<day>[[:digit:]]{2}) (?<hour>[[:digit:]]{2}):(?<minutes>[[:digit:]]{2}):(?<seconds>[[:digit:]]{2})\.(?<microseconds>[[:digit:]]{6})`
174+
)
175+
176+
var (
177+
klogPrefix = regexp.MustCompile(`^(?<indention>[[:blank:]]*)` +
178+
`(?:` + source + `: )?` + // `go test` source code
179+
severity +
180+
datetime +
181+
`(?: +` + pid + ` ` + source + `)?` + // klog pid + source code
182+
`\] `)
183+
184+
indentionIndex = lookupSubmatch("indention")
185+
severityIndex = lookupSubmatch("severity")
186+
)
187+
188+
func lookupSubmatch(name string) int {
189+
names := klogPrefix.SubexpNames()
190+
for i, n := range names {
191+
if n == name {
192+
return i
193+
}
194+
}
195+
panic(fmt.Errorf("named submatch %q not found in %q", name, klogPrefix.String()))
196+
}
197+
198+
// parseLine deals with one non-empty line. It returns the result of yield and
199+
// potentially the next line and/or a read error. If it doesn't have any new
200+
// data to process, it returns the empty string and a nil error.
201+
func parseLine(reader *bufio.Reader, line string, yield func(Entry) bool) (string, bool, error) {
202+
match := klogPrefix.FindStringSubmatchIndex(line)
203+
if match == nil {
204+
cont := yield(&OtherEntry{Data: line})
205+
return "", cont, nil
206+
}
207+
208+
e := &KlogEntry{
209+
Data: line,
210+
Severity: Severity(line[match[2*severityIndex]]),
211+
// TODO (?): store more of the fields that have been identified
212+
}
213+
// Deal with potential line continuation of multi-line string value,
214+
// if necessary.
215+
if !strings.HasSuffix(line, "=<\n") {
216+
return "", yield(e), nil
217+
}
218+
indention := line[match[2*indentionIndex]:match[2*indentionIndex+1]]
219+
for {
220+
var err error
221+
line, err = reader.ReadString('\n')
222+
if !strings.HasPrefix(line, indention) ||
223+
!strings.HasPrefix(line[len(indention):], "\t") && !strings.HasPrefix(line[len(indention):], " >") {
224+
// Some other line (wrong indention or wrong continuation).
225+
// Yield what we have so far and the go back to processing that new line.
226+
cont := yield(e)
227+
return line, cont, err
228+
}
229+
e.Data += line
230+
if err != nil {
231+
return "", yield(e), err
232+
}
233+
}
234+
}

0 commit comments

Comments
 (0)