Skip to content

Commit d248009

Browse files
committed
Implement log analyzer
Signed-off-by: Ben <[email protected]>
1 parent 1fd00fb commit d248009

File tree

6 files changed

+372
-3
lines changed

6 files changed

+372
-3
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/kedacore/keda/v2 v2.7.0
2929
github.com/kube-object-storage/lib-bucket-provisioner v0.0.0-20221122204822-d1a8c34382f1
3030
github.com/libopenstorage/secrets v0.0.0-20240416031220-a17cf7f72c6c
31+
github.com/lithammer/fuzzysearch v1.1.8
3132
github.com/marstr/randname v0.0.0-20200428202425-99aca53a2176
3233
github.com/onsi/ginkgo/v2 v2.20.2
3334
github.com/onsi/gomega v1.34.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,8 @@ github.com/libopenstorage/operator v0.0.0-20200725001727-48d03e197117/go.mod h1:
722722
github.com/libopenstorage/stork v1.3.0-beta1.0.20200630005842-9255e7a98775/go.mod h1:qBSzYTJVHlOMg5RINNiHD1kBzlasnrc2uKLPZLgu1Qs=
723723
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
724724
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
725+
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
726+
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
725727
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
726728
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
727729
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=

pkg/diagnostics/collect.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func (c *Collector) CollectPodsLogs(listOptions client.ListOptions) {
140140

141141
c.CollectDescribe("pod", pod.Name)
142142

143-
podLogs, _ := util.GetPodLogs(*pod)
143+
podLogs, _ := util.GetPodLogs(*pod, nil, false)
144144
for containerName, containerLog := range podLogs {
145145
targetFile := fmt.Sprintf("%s/%s-%s.log", c.folderName, pod.Name, containerName)
146146
err := util.SaveStreamToFile(containerLog, targetFile)

pkg/diagnostics/diagnostics.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func Cmd() *cobra.Command {
2525
CmdDbDump(),
2626
CmdAnalyze(),
2727
CmdReport(),
28+
CmdLogAnalysis(),
2829
)
2930
return cmd
3031
}
@@ -123,6 +124,23 @@ func CmdAnalyzeResources() *cobra.Command {
123124
return cmd
124125
}
125126

127+
// CmdLogAnalysis returns a CLI command
128+
func CmdLogAnalysis() *cobra.Command {
129+
cmd := &cobra.Command{
130+
Use: "log-analysis",
131+
Short: "Run the log analyzer on all NooBaa pods",
132+
Run: RunLogAnalysis,
133+
}
134+
cmd.Flags().Bool("fuzzy", false, "(Experimental) Toggle fuzzy matching for the search string")
135+
cmd.Flags().Int64("tail", 1000, "Number of lines to tail from the logs, minimum 1, default 1000")
136+
cmd.Flags().Bool("noobaa-time", false, "Use NooBaa-provided timestamps instead of the Kubernetes ones (~10ms earlier than Kubernetes)")
137+
cmd.Flags().BoolP("case-insensitive", "i", false, "Toggle search-string case insensitivity (similar to grep's -i flag)")
138+
cmd.Flags().BoolP("whole-string", "w", false, "Match the whole search string as a single word (similar to grep's -w flag)")
139+
cmd.Flags().Bool("prefer-line", false, "Prefer to print the line containing the search string when it doesn't contain a timestamp")
140+
141+
return cmd
142+
}
143+
126144
/////// Deprecated Functions ///////
127145

128146
// CmdDbDumpDeprecated returns a CLI command

pkg/diagnostics/log_analysis.go

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
package diagnostics
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"regexp"
7+
"strings"
8+
"time"
9+
10+
"github.com/noobaa/noobaa-operator/v5/pkg/options"
11+
"github.com/noobaa/noobaa-operator/v5/pkg/util"
12+
"github.com/lithammer/fuzzysearch/fuzzy"
13+
14+
corev1 "k8s.io/api/core/v1"
15+
"k8s.io/apimachinery/pkg/labels"
16+
"sigs.k8s.io/controller-runtime/pkg/client"
17+
18+
"github.com/spf13/cobra"
19+
)
20+
21+
// LogAnalysisOptions holds parameters for log analysis
22+
type LogAnalysisOptions struct {
23+
tailLines int64
24+
fuzzyMatch bool
25+
searchString string
26+
noobaaTimestamp bool
27+
matchWholeString bool
28+
caseInsensitivity bool
29+
showLineIfNoTimestamp bool
30+
}
31+
32+
const (
33+
noobaaOutputTimestampFormat = "02/01/2006 15:04:05"
34+
// To be used in case of additional NooBaa operators in the future
35+
noobaaOperatorPodNamePrefix = "noobaa-operator"
36+
operatorTimestampPrefix = `time="`
37+
operatorWTimestampPrefix = "W"
38+
)
39+
40+
var (
41+
ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
42+
)
43+
44+
// RunLogAnalysis runs a CLI command
45+
func RunLogAnalysis(cmd *cobra.Command, args []string) {
46+
tailLines, _ := cmd.Flags().GetInt64("tail")
47+
fuzzyMatch, _ := cmd.Flags().GetBool("fuzzy")
48+
noobaaTimestamp, _ := cmd.Flags().GetBool("noobaa-time")
49+
showLineIfNoTimestamp, _ := cmd.Flags().GetBool("prefer-line")
50+
matchWholeString, _ := cmd.Flags().GetBool("whole-string")
51+
caseInsensitivity, _ := cmd.Flags().GetBool("case-insensitive")
52+
searchString := ""
53+
54+
analysisOptions := LogAnalysisOptions{
55+
tailLines: tailLines,
56+
fuzzyMatch: fuzzyMatch,
57+
searchString: searchString,
58+
noobaaTimestamp: noobaaTimestamp,
59+
matchWholeString: matchWholeString,
60+
caseInsensitivity: caseInsensitivity,
61+
showLineIfNoTimestamp: showLineIfNoTimestamp,
62+
}
63+
64+
validateLogAnalysisParameters(&analysisOptions, args)
65+
podSelector, _ := labels.Parse("app=noobaa")
66+
listOptions := client.ListOptions{Namespace: options.Namespace, LabelSelector: podSelector}
67+
CollectAndAnalyzeLogs(listOptions, &analysisOptions)
68+
}
69+
70+
// validateLogAnalysisParameters validates the parameters for log analysis
71+
func validateLogAnalysisParameters(analysisOptions *LogAnalysisOptions, args []string) {
72+
log := util.Logger()
73+
if len(args) != 1 || args[0] == "" {
74+
analysisOptions.searchString = util.ShowStringPrompt("Provide a search string")
75+
} else {
76+
analysisOptions.searchString = args[0]
77+
}
78+
79+
if analysisOptions.tailLines < 1 {
80+
log.Fatalf("❌ Tail must be a whole positive number")
81+
}
82+
83+
if analysisOptions.fuzzyMatch && analysisOptions.matchWholeString {
84+
log.Fatalf("❌ Cannot use both fuzzy matching and whole-string matching")
85+
}
86+
}
87+
88+
// CollectAndAnalyzeLogs collects and analyzes logs of all existing noobaa pods
89+
func CollectAndAnalyzeLogs(listOptions client.ListOptions, analysisOptions *LogAnalysisOptions) {
90+
log := util.Logger()
91+
chosenTimestamp := ""
92+
if analysisOptions.noobaaTimestamp {
93+
chosenTimestamp = "NooBaa"
94+
} else {
95+
chosenTimestamp = "Kubernetes"
96+
}
97+
log.Println()
98+
log.Println("✨────────────────────────────────────────────✨")
99+
log.Printf(" Collecting and analyzing pod logs -")
100+
log.Printf(" Search string: %s", analysisOptions.searchString)
101+
log.Printf(" Case insensitivity: %t", analysisOptions.caseInsensitivity)
102+
log.Printf(" Match whole string: %t", analysisOptions.matchWholeString)
103+
log.Printf(" From the last %d lines", analysisOptions.tailLines)
104+
log.Printf(" Using %s timestamps", chosenTimestamp)
105+
log.Println(" Found occurrences will be printed below")
106+
log.Println(" in the format <pod name>:<container name>")
107+
log.Println("✨────────────────────────────────────────────✨")
108+
podList := &corev1.PodList{}
109+
if !util.KubeList(podList, &listOptions) {
110+
log.Printf(`❌ failed to get NooBaa pod list within namespace %s\n`, options.Namespace)
111+
return
112+
}
113+
for i := range podList.Items {
114+
pod := &podList.Items[i]
115+
analyzePodLogs(pod, analysisOptions)
116+
}
117+
}
118+
119+
// analyzePodLogs will count the number of occurrences of the search string in a pod log
120+
// as well as find and print the timestamps of the first and last occurrence of
121+
// the search string in the logs
122+
func analyzePodLogs(pod *corev1.Pod, analysisOptions *LogAnalysisOptions) {
123+
log := util.Logger()
124+
podLogs, err := util.GetPodLogs(*pod, &analysisOptions.tailLines, true)
125+
if err != nil {
126+
log.Printf("❌ Failed to get logs for pod %s: %v", pod.Name, err)
127+
return
128+
}
129+
if analysisOptions.caseInsensitivity {
130+
analysisOptions.searchString = strings.ToLower(analysisOptions.searchString)
131+
}
132+
stringBoundaryRegex := compileStringBoundaryRegex(analysisOptions) // Compiled here for better efficiency
133+
for containerName, containerLog := range podLogs {
134+
firstAppearanceFound := false
135+
firstAppearanceTimestamp := ""
136+
lastAppearanceTimestamp := ""
137+
lastOccurrenceLine := ""
138+
log.Printf("Analyzing %s:%s", pod.Name, containerName)
139+
defer containerLog.Close()
140+
occurrenceCounter := 0
141+
scanner := bufio.NewScanner(containerLog)
142+
for scanner.Scan() {
143+
line := scanner.Text()
144+
// Clean line from ANSI escape codes
145+
if !strings.Contains(pod.Name, noobaaOperatorPodNamePrefix) {
146+
line = sanitizeANSI(line)
147+
}
148+
lineContainsMatch := stringMatchCheck(line, stringBoundaryRegex, analysisOptions)
149+
if lineContainsMatch {
150+
if !firstAppearanceFound {
151+
firstAppearanceFound = true
152+
firstAppearanceTimestamp = extractTimeString(pod, line, *analysisOptions)
153+
}
154+
occurrenceCounter++
155+
lastOccurrenceLine = line
156+
}
157+
}
158+
lastAppearanceTimestamp = extractTimeString(pod, lastOccurrenceLine, *analysisOptions)
159+
if occurrenceCounter == 0 {
160+
log.Println("No occurrences found")
161+
} else {
162+
log.Printf("Hits: %d", occurrenceCounter)
163+
log.Printf("Earliest appearance: %s", firstAppearanceTimestamp)
164+
if occurrenceCounter > 1 {
165+
log.Printf("Latest appearance: %s", lastAppearanceTimestamp)
166+
}
167+
}
168+
log.Println("──────────────────────────────────────────────────────────────────────────────────")
169+
}
170+
}
171+
172+
// sanitizeANSI removes ANSI escape codes from a string
173+
func sanitizeANSI(line string) string {
174+
// Remove ANSI escape codes from the line
175+
// This is done to avoid the terminal from interpreting them as color codes
176+
// and printing them as garbage characters
177+
// The regex pattern is taken from
178+
return ansiRegex.ReplaceAllString(line, "")
179+
}
180+
181+
// compileStringBoundaryRegex compiles a word boundary regex pattern for the search string
182+
func compileStringBoundaryRegex(analysisOptions *LogAnalysisOptions) *regexp.Regexp {
183+
var stringBoundarySearchPattern *regexp.Regexp
184+
stringBoundaryPattern := fmt.Sprintf(`\b%s\b`, regexp.QuoteMeta(analysisOptions.searchString))
185+
if analysisOptions.caseInsensitivity {
186+
stringBoundarySearchPattern = regexp.MustCompile("(?i)" + stringBoundaryPattern)
187+
} else {
188+
stringBoundarySearchPattern = regexp.MustCompile(stringBoundaryPattern)
189+
}
190+
return stringBoundarySearchPattern
191+
}
192+
193+
// stringMatchCheck checks if a line contains a match to the search string
194+
func stringMatchCheck (line string, stringBoundaryRegex *regexp.Regexp, analysisOptions *LogAnalysisOptions) bool {
195+
if analysisOptions.matchWholeString {
196+
return wholestringMatchCheck(line, stringBoundaryRegex)
197+
} else {
198+
return partialMatchCheck(line, analysisOptions)
199+
}
200+
}
201+
202+
// wholestringMatchCheck checks if a line contains a whole string match to the search string
203+
// Mostly used for readability and organization purposes
204+
func wholestringMatchCheck (line string, stringBoundaryRegex *regexp.Regexp) bool {
205+
return stringBoundaryRegex.MatchString(line)
206+
}
207+
208+
// partialMatchCheck checks if a line contains a partial/fuzzy match to the search string
209+
func partialMatchCheck (line string, analysisOptions *LogAnalysisOptions) bool {
210+
if analysisOptions.fuzzyMatch {
211+
fuzzyCaseInsensitiveMatch := analysisOptions.caseInsensitivity && fuzzy.MatchNormalized(analysisOptions.searchString, line)
212+
fuzzyCaseSensitiveMatch := fuzzy.Match(analysisOptions.searchString, line)
213+
return fuzzyCaseInsensitiveMatch || fuzzyCaseSensitiveMatch
214+
} else {
215+
// Check for a match by temporarily casting the line string to lowercase
216+
// (the search string is cast in the beginning of analyzePodLogs)
217+
caseInsensitiveMatch := analysisOptions.caseInsensitivity && strings.Contains(strings.ToLower(line), analysisOptions.searchString)
218+
caseSensitiveMatch := strings.Contains(line, analysisOptions.searchString)
219+
return caseInsensitiveMatch || caseSensitiveMatch
220+
}
221+
}
222+
223+
// extractTimeString extracts the timestamp from a log line by checking which pod
224+
// it originated from and redirecting it to the appropriate extraction function
225+
func extractTimeString(pod *corev1.Pod, line string, analysisOptions LogAnalysisOptions) string {
226+
if analysisOptions.noobaaTimestamp {
227+
if strings.Contains(pod.Name, noobaaOperatorPodNamePrefix) {
228+
return extractOperatorTimestampString(line, analysisOptions.showLineIfNoTimestamp)
229+
} else {
230+
return extractCoreTimestampString(line, analysisOptions.showLineIfNoTimestamp)
231+
}
232+
} else {
233+
return extractKubernetesTimestampString(line)
234+
}
235+
}
236+
237+
// extractKubernetesTimestampString extracts the timestamp from a Kubernetes log line
238+
func extractKubernetesTimestampString(line string) string {
239+
// Example log line:
240+
// 2024-12-10T07:27:16.856641898Z Dec-10 7:27:16.847 [BGWorkers/36]...
241+
splitLine := strings.SplitN(line, " ", 2)
242+
return splitLine[0]
243+
}
244+
245+
// extractCoreTimestampString extracts, parses and formats a timestamp string
246+
// from pods running NooBaa Core code (core, endpoint, PV pod)
247+
func extractCoreTimestampString(line string, showLineIfNoTimestamp bool) string {
248+
// Example log line:
249+
// Dec-9 15:16:31.621 [BGWorkers/36] [L0] ...
250+
const minimumRequiredIndices = 2
251+
// Example split result:
252+
// ["Dec-9", "15:16:31.621", "[BGWorkers..."]
253+
splitLine := strings.SplitN(line, " ", 3)
254+
if len(splitLine) < minimumRequiredIndices {
255+
return timeParsingError(showLineIfNoTimestamp, line)
256+
}
257+
lineDate := splitLine[0]
258+
lineTime := splitLine[1]
259+
// The year is assumed to be the current one since it's not provided
260+
year := time.Now().Year()
261+
layout := "Jan-2-2006 15:04:05.000"
262+
timestampString := fmt.Sprintf("%s-%d %s", lineDate, year, lineTime)
263+
parsedTime, err := time.Parse(layout, timestampString)
264+
if err != nil {
265+
return timeParsingError(showLineIfNoTimestamp, line)
266+
}
267+
return parsedTime.Format(noobaaOutputTimestampFormat)
268+
}
269+
270+
// extractOperatorTimestampString extracts, parses, formats and returns a timestamp
271+
// string from the NooBaa Operator pod logs
272+
func extractOperatorTimestampString(line string, showLineIfNoTimestamp bool) string {
273+
if strings.HasPrefix(line, operatorTimestampPrefix) {
274+
return extractStandardOperatorTimestampString(line, showLineIfNoTimestamp)
275+
} else if strings.HasPrefix(line, operatorWTimestampPrefix) {
276+
return extractOperatorWTimestampString(line, showLineIfNoTimestamp)
277+
}
278+
return timeParsingError(showLineIfNoTimestamp, line)
279+
}
280+
281+
// extractStandardOperatorTimestampString extracts the timestamp in case of a standard operator log line
282+
// Example:
283+
// time="2024-12-10T07:27:36Z" level=info msg="...
284+
func extractStandardOperatorTimestampString(line string, showLineIfNoTimestamp bool) string {
285+
secondQuotesIndex := strings.Index(line[len(operatorTimestampPrefix):], `"`)
286+
if secondQuotesIndex == -1 {
287+
return timeParsingError(showLineIfNoTimestamp, line)
288+
}
289+
timestampString := line[len(operatorTimestampPrefix) : len(operatorTimestampPrefix) + secondQuotesIndex]
290+
// Parse the date using RFC3339 layout
291+
const operatorTimestampFormat = time.RFC3339
292+
parsedTimestamp, err := time.Parse(operatorTimestampFormat, timestampString)
293+
if err != nil {
294+
return timeParsingError(showLineIfNoTimestamp, line)
295+
}
296+
return parsedTimestamp.Format(noobaaOutputTimestampFormat)
297+
}
298+
299+
// extractOperatorWTimestampString extracts the timestamp in case of a non-standard operator log line
300+
// Example:
301+
// W1209 13:41:05.890285 1 reflector.go:484...
302+
func extractOperatorWTimestampString(line string, showLineIfNoTimestamp bool) string {
303+
const minimumWTimeParseLength = 22
304+
if len(line) < minimumWTimeParseLength {
305+
return timeParsingError(showLineIfNoTimestamp, line)
306+
}
307+
wStringDayMonthStartIndex := 1
308+
wStringDayMonthEndIndex := 5
309+
wStringTimeStartIndex := 6
310+
wStringTimeEndIndex := 21
311+
datePart := line[wStringDayMonthStartIndex : wStringDayMonthEndIndex]
312+
timePart := line[wStringTimeStartIndex : wStringTimeEndIndex]
313+
day := datePart[2:]
314+
month := datePart[:2]
315+
// The year is assumed to be the current one since it's not provided
316+
year := time.Now().Year()
317+
fullTimeStr := fmt.Sprintf("%s/%s/%d %s", day, month, year, timePart)
318+
return fullTimeStr
319+
}
320+
321+
// timeParsingError returns an error message if the timestamp
322+
// could not be parsed based on the user's prefer-line setting
323+
func timeParsingError(showLineIfNoTimestamp bool, line string) string {
324+
absentTimestampErrorWithLine := fmt.Sprintf("Could not parse timestamp in line %s", line)
325+
const absentTimestampError = "No timestamp found"
326+
if showLineIfNoTimestamp {
327+
return absentTimestampErrorWithLine
328+
}
329+
return absentTimestampError
330+
}

0 commit comments

Comments
 (0)