From 6adccab7d44a8e5f3c94f2cf36d4ac250642a13a Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 15 Jun 2026 11:06:03 +0200 Subject: [PATCH 01/13] check_logfile: imporve file scanning and keep track of lines matched in each file apply conditional filters to the lines early on while adding them, this was not the case before. now the count of lines being passing the filter is correct. add function to generate a detail string from files and the count of matched lines in the file. this can be added to the detailed syntax when needed. if there are no lines matched across all files, change the main output line to say "No matching lines found in files" --- pkg/snclient/check_logfile.go | 125 +++++++++++++++++++++-------- pkg/snclient/check_logfile_test.go | 10 +++ 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 376278f4..451e86b3 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -22,14 +22,14 @@ func init() { var numReg = regexp.MustCompile(`\d+`) type CheckLogFile struct { - snc *Agent - FilePath []string - Paths string - LineDelimiter string - TimestampPattern string - ColumnDelimiter string - LabelPattern []string - Offset string // Changed to string to detect if user provided it + snc *Agent + FilePathPatterns []string + FilePathPatternsCS string + LineDelimiter string + TimestampPattern string + ColumnDelimiter string + LabelPattern []string + Offset string // Changed to string to detect if user provided it } type LogLine struct { @@ -69,8 +69,8 @@ func (c *CheckLogFile) Build() *CheckData { emptySyntax: "%(status) - No files found", emptyState: CheckExitUnknown, args: map[string]CheckArgument{ - "file": {value: &c.FilePath, description: "The file that should be checked"}, - "files": {value: &c.Paths, description: "Comma separated list of files"}, + "file": {value: &c.FilePathPatterns, description: "The file that should be checked"}, + "files": {value: &c.FilePathPatternsCS, description: "Comma separated list of files"}, "offset": {value: &c.Offset, description: "Starting position (in bytes) for scanning the file (0 for beginning). This overrides any saved offset"}, "line-split": {value: &c.LineDelimiter, description: "Character string used to split a file into several lines (default \\n)"}, "column-split": {value: &c.ColumnDelimiter, description: "Tab split default: \\t"}, @@ -104,8 +104,8 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ return nil, fmt.Errorf("module CheckLogFile is not enabled in /modules section") } - c.FilePath = append(c.FilePath, strings.Split(c.Paths, ",")...) - if len(c.FilePath) == 0 { + c.FilePathPatterns = append(c.FilePathPatterns, strings.Split(c.FilePathPatternsCS, ",")...) + if len(c.FilePathPatterns) == 0 { return nil, fmt.Errorf("no file defined") } @@ -122,47 +122,104 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ } } + // patterns are for the file names/paths, not file contents! allowedPattern := c.getAllowedPattern() - totalLineCount := 0 - for _, fileName := range c.FilePath { + totalLineIndexedCount := 0 + checkedFilesWithMatchedEntries := make(map[string]int, 0) + + for _, fileName := range c.FilePathPatterns { if fileName == "" { continue } - count := 0 + + lineIndexedInThisFilePattern := 0 files, err := filepath.Glob(fileName) if err != nil { return nil, fmt.Errorf("could not get files for pattern %s, error was: %s", fileName, err.Error()) } + for _, fileName := range files { if !c.matchPattern(fileName, allowedPattern) { + log.Tracef("check_logfile rejecting file: %s as it does not any match patterns: %v ", fileName, allowedPattern) + return nil, fmt.Errorf("file %s does not match any allowed pattern", fileName) } - tmpCount, err := c.addFile(fileName, check, patterns) + + log.Debugf("check_logfile adding file: %s", fileName) + entries, lineIndex, err := c.addFile(fileName, check, patterns) if err != nil { return nil, fmt.Errorf("error for file %s, error was: %s", fileName, err.Error()) } - count += tmpCount + log.Debugf("check_logfile file: %s | returned entries: %v | lines indexed: %d", fileName, entries, lineIndex) + + lineIndexedInThisFilePattern += lineIndex + check.listData = append(check.listData, entries...) + checkedFilesWithMatchedEntries[fileName] = len(entries) } - totalLineCount += count + + totalLineIndexedCount += lineIndexedInThisFilePattern } + check.details = map[string]string{ - "total": fmt.Sprintf("%d", totalLineCount), + "total": fmt.Sprintf("%d", totalLineIndexedCount), + "file_counts": c.buildFileCountsDetailString(checkedFilesWithMatchedEntries), + } + + if len(check.listData) == 0 { + check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) } return check.Finalize() } -func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[string]*regexp.Regexp) (int, error) { +func (c *CheckLogFile) buildFileCountsDetailString(checkedFilesWithMatchedEntries map[string]int) (fileCountDetails string) { + type kv struct { + file string + count int + } + sorted := make([]kv, 0, len(checkedFilesWithMatchedEntries)) + for file, count := range checkedFilesWithMatchedEntries { + sorted = append(sorted, kv{file, count}) + } + + slices.SortFunc(sorted, func(a, b kv) int { + if a.file < b.file { + return -1 + } + if a.file > b.file { + return 1 + } + if a.count < b.count { + return -1 + } + if a.count > b.count { + return 1 + } + + return 0 + }) + + detailParts := make([]string, 0, len(sorted)) + for _, item := range sorted { + detailParts = append(detailParts, fmt.Sprintf("%s: %d", item.file, item.count)) + } + + fileCountDetails = strings.Join(detailParts, ", ") + + return fileCountDetails +} + +func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[string]*regexp.Regexp) (entries []map[string]string, lineIndex int, err error) { file, err := os.Open(fileName) if err != nil { - return 0, fmt.Errorf("could not open file: %s error was: %s", fileName, err.Error()) + return entries, 0, fmt.Errorf("could not open file: %s error was: %s", fileName, err.Error()) } defer file.Close() info, err := file.Stat() if err != nil { - return 0, fmt.Errorf("could not stat file %s: %s", fileName, err.Error()) + return entries, 0, fmt.Errorf("could not stat file %s: %s", fileName, err.Error()) } currentInode := getInode(fileName) @@ -170,7 +227,7 @@ func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[str startOffset, err := c.getStartOffset(fileName, currentSize, currentInode) if err != nil { - return 0, err + return entries, 0, err } saveState := true @@ -188,25 +245,23 @@ func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[str // seek to start offset if startOffset > 0 { if startOffset > currentSize { - return 0, nil + return entries, 0, nil } _, err = file.Seek(startOffset, 0) if err != nil { saveState = false - return 0, fmt.Errorf("failed to seek to offset %d in %s: %w", startOffset, fileName, err) + return entries, 0, fmt.Errorf("failed to seek to offset %d in %s: %w", startOffset, fileName, err) } } scanner := bufio.NewScanner(file) scanner.Split(c.getCustomSplitFunction()) - okReset := len(check.okThreshold) > 0 + okThresholdNotEmpty := len(check.okThreshold) > 0 lineStorage := make([]map[string]string, 0) columnNumbers := c.getRequiredColumnNumbers(check) - // filter each line - var lineIndex int for lineIndex = 0; scanner.Scan(); lineIndex++ { line := scanner.Text() entry := map[string]string{ @@ -230,9 +285,16 @@ func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[str } } + if !check.MatchMapCondition(check.filter, entry, false) { + log.Tracef("file: %s , line : %s, did not match the filter set in the check, not ading to check.listData", fileName, line) + + continue + } + lineStorage = append(lineStorage, entry) - // Do not check for OK with empty condition list, it would match all - if okReset && check.MatchMapCondition(check.okThreshold, entry, true) { + + // Do not check for OK condition if the OK condition list is empty, it would match everything + if okThresholdNotEmpty && check.MatchMapCondition(check.okThreshold, entry, true) { // add and empty entry with the current line count to the list data to keep track of line count entry := map[string]string{ "_count": fmt.Sprintf("%d", len(lineStorage)), @@ -241,9 +303,8 @@ func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[str lineStorage = make([]map[string]string, 0) } } - check.listData = append(check.listData, lineStorage...) - return lineIndex, nil + return lineStorage, lineIndex, nil } func (c *CheckLogFile) getStartOffset(fileName string, currentSize int64, currentInode uint64) (int64, error) { diff --git a/pkg/snclient/check_logfile_test.go b/pkg/snclient/check_logfile_test.go index 4811ad54..8b90dd93 100644 --- a/pkg/snclient/check_logfile_test.go +++ b/pkg/snclient/check_logfile_test.go @@ -116,3 +116,13 @@ func TestCheckLogFileColumnN(t *testing.T) { StopTestAgent(t, snc) } + +func TestCheckLogFileFileExistsButHasNoLines(t *testing.T) { + snc := StartTestAgent(t, testLogfileConfig) + + res := snc.RunCheck("check_logfile", []string{"files=./t/test*", "filter=line LIKE this-pattern-does-not-exist-in-the-test-files", "show-all"}) + assert.Equalf(t, CheckExitUnknown, res.State, "state UNKNOWN") + assert.Contains(t, string(res.BuildPluginOutput()), "UNKNOWN - No matching lines found in files ") + + StopTestAgent(t, snc) +} From e1d777ce234f2301bf05ef0279a9361e993a12c1 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Tue, 16 Jun 2026 17:45:07 +0200 Subject: [PATCH 02/13] change the default empty state to ok differentiate between empty states 1) where no files are found, due to given file paths 2) where files are found but do not contain matched lines. write different outputs for both of them, and add tests --- docs/checks/commands/check_logfile.md | 2 +- pkg/snclient/check_logfile.go | 7 +++++-- pkg/snclient/check_logfile_test.go | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/checks/commands/check_logfile.md b/docs/checks/commands/check_logfile.md index a0db1f3e..0f4d0467 100644 --- a/docs/checks/commands/check_logfile.md +++ b/docs/checks/commands/check_logfile.md @@ -58,7 +58,7 @@ Naemon Config | Argument | Default Value | | ------------- | ---------------------------------------------------------------------- | -| empty-state | 3 (UNKNOWN) | +| empty-state | 0 (OK) | | empty-syntax | %(status) - No files found | | top-syntax | %(status) - %(problem_count)/%(count) lines (%(count)) %(problem_list) | | ok-syntax | %(status) - All %(count) / %(total) Lines OK | diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 451e86b3..4d78e8e3 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -67,7 +67,7 @@ func (c *CheckLogFile) Build() *CheckData { okSyntax: "%(status) - All %(count) / %(total) Lines OK", topSyntax: "%(status) - %(problem_count)/%(count) lines (%(count)) %(problem_list)", emptySyntax: "%(status) - No files found", - emptyState: CheckExitUnknown, + emptyState: CheckExitOK, args: map[string]CheckArgument{ "file": {value: &c.FilePathPatterns, description: "The file that should be checked"}, "files": {value: &c.FilePathPatternsCS, description: "Comma separated list of files"}, @@ -166,8 +166,11 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ "file_counts": c.buildFileCountsDetailString(checkedFilesWithMatchedEntries), } - if len(check.listData) == 0 { + if len(checkedFilesWithMatchedEntries) == 0 { + check.okSyntax = "%(status) - No files found to search lines in" + } else if len(check.listData) == 0 { check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) + check.emptyStateSet = true } return check.Finalize() diff --git a/pkg/snclient/check_logfile_test.go b/pkg/snclient/check_logfile_test.go index 8b90dd93..a98b7856 100644 --- a/pkg/snclient/check_logfile_test.go +++ b/pkg/snclient/check_logfile_test.go @@ -121,8 +121,18 @@ func TestCheckLogFileFileExistsButHasNoLines(t *testing.T) { snc := StartTestAgent(t, testLogfileConfig) res := snc.RunCheck("check_logfile", []string{"files=./t/test*", "filter=line LIKE this-pattern-does-not-exist-in-the-test-files", "show-all"}) - assert.Equalf(t, CheckExitUnknown, res.State, "state UNKNOWN") - assert.Contains(t, string(res.BuildPluginOutput()), "UNKNOWN - No matching lines found in files ") + assert.Equalf(t, CheckExitOK, res.State, "state should be OK") + assert.Contains(t, string(res.BuildPluginOutput()), "OK - No matching lines found in files") + + StopTestAgent(t, snc) +} + +func TestCheckLogFileFileDoesNotExist(t *testing.T) { + snc := StartTestAgent(t, testLogfileConfig) + + res := snc.RunCheck("check_logfile", []string{"files=./t/testfiledoesnotexist*"}) + assert.Equalf(t, CheckExitOK, res.State, "state should be OK") + assert.Contains(t, string(res.BuildPluginOutput()), "OK - No files found to search lines in") StopTestAgent(t, snc) } From 8a14be2f2a4f37c1b33c1ac83d029b099f8cbb9d Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Tue, 23 Jun 2026 14:49:13 +0200 Subject: [PATCH 03/13] check_logfile: empty state is unknown, conditionally set to 0 if files exist without matched lines checkdata.finalizeOutput() requires explicit filters or implicit argument filters to apply emptySyntax on output. apply empty syntax on empty state, by setting the "file" and files" argument is set as a filter. --- docs/checks/commands/check_logfile.md | 2 +- pkg/snclient/check_logfile.go | 11 ++++++----- pkg/snclient/check_logfile_test.go | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/checks/commands/check_logfile.md b/docs/checks/commands/check_logfile.md index 0f4d0467..a0db1f3e 100644 --- a/docs/checks/commands/check_logfile.md +++ b/docs/checks/commands/check_logfile.md @@ -58,7 +58,7 @@ Naemon Config | Argument | Default Value | | ------------- | ---------------------------------------------------------------------- | -| empty-state | 0 (OK) | +| empty-state | 3 (UNKNOWN) | | empty-syntax | %(status) - No files found | | top-syntax | %(status) - %(problem_count)/%(count) lines (%(count)) %(problem_list) | | ok-syntax | %(status) - All %(count) / %(total) Lines OK | diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 4d78e8e3..f8709a9e 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -67,10 +67,10 @@ func (c *CheckLogFile) Build() *CheckData { okSyntax: "%(status) - All %(count) / %(total) Lines OK", topSyntax: "%(status) - %(problem_count)/%(count) lines (%(count)) %(problem_list)", emptySyntax: "%(status) - No files found", - emptyState: CheckExitOK, + emptyState: CheckExitUnknown, args: map[string]CheckArgument{ - "file": {value: &c.FilePathPatterns, description: "The file that should be checked"}, - "files": {value: &c.FilePathPatternsCS, description: "Comma separated list of files"}, + "file": {value: &c.FilePathPatterns, description: "The file that should be checked", isFilter: true}, + "files": {value: &c.FilePathPatternsCS, description: "Comma separated list of files", isFilter: true}, "offset": {value: &c.Offset, description: "Starting position (in bytes) for scanning the file (0 for beginning). This overrides any saved offset"}, "line-split": {value: &c.LineDelimiter, description: "Character string used to split a file into several lines (default \\n)"}, "column-split": {value: &c.ColumnDelimiter, description: "Tab split default: \\t"}, @@ -167,10 +167,11 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ } if len(checkedFilesWithMatchedEntries) == 0 { - check.okSyntax = "%(status) - No files found to search lines in" + check.emptySyntax = fmt.Sprintf("%%(status) - No files found to search lines in, search paths: '%s' ", strings.Join(c.FilePathPatterns, ",")) } else if len(check.listData) == 0 { - check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) + check.emptyState = CheckExitOK check.emptyStateSet = true + check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) } return check.Finalize() diff --git a/pkg/snclient/check_logfile_test.go b/pkg/snclient/check_logfile_test.go index a98b7856..b9f4fe43 100644 --- a/pkg/snclient/check_logfile_test.go +++ b/pkg/snclient/check_logfile_test.go @@ -52,7 +52,7 @@ func TestCheckLogFilePathWildCards(t *testing.T) { res = snc.RunCheck("check_logfile", []string{"files=./t/test*", "warn=line LIKE WARNING"}) assert.Equalf(t, CheckExitOK, res.State, "state OK") - assert.Contains(t, string(res.BuildPluginOutput()), "OK - All 0 / 0") + assert.Contains(t, string(res.BuildPluginOutput()), "OK - No matching lines found in files ") StopTestAgent(t, snc) } @@ -131,8 +131,8 @@ func TestCheckLogFileFileDoesNotExist(t *testing.T) { snc := StartTestAgent(t, testLogfileConfig) res := snc.RunCheck("check_logfile", []string{"files=./t/testfiledoesnotexist*"}) - assert.Equalf(t, CheckExitOK, res.State, "state should be OK") - assert.Contains(t, string(res.BuildPluginOutput()), "OK - No files found to search lines in") + assert.Equalf(t, CheckExitUnknown, res.State, "state should be UNKNOWN") + assert.Contains(t, string(res.BuildPluginOutput()), "UNKNOWN - No files found to search lines in") StopTestAgent(t, snc) } From 752dd5470f8239a44ea1cf5164f389a6396d9a32 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Wed, 8 Jul 2026 18:33:49 +0200 Subject: [PATCH 04/13] check_logfile: fix bug and adjust output FilePathPatternsCS was default initialized to "" , and when split up for commas it became an string[]{""} This empty string then messed up some logs, and prevented returning an error for the case where no file paths were specified additionally, add the file-line count details to the ok state syntax. change the test files a bit, and add a test specifically checking a line that only exists on the second test log file, and how the file-line count details are added move processing of the arguments into a separate function, to prevent linter from complaining about long CheckLogFile.Check function --- pkg/snclient/check_logfile.go | 75 ++++++++++++++++++------------ pkg/snclient/check_logfile_test.go | 19 ++++++++ pkg/snclient/t/test.log | 4 +- pkg/snclient/t/test2.log | 4 +- 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index f8709a9e..188dde15 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -39,8 +39,10 @@ type LogLine struct { func NewCheckLogFile() CheckHandler { return &CheckLogFile{ - LineDelimiter: "\n", - ColumnDelimiter: "\t", + LineDelimiter: "\n", + ColumnDelimiter: "\t", + FilePathPatterns: make([]string, 0), + FilePathPatternsCS: "", } } @@ -99,32 +101,11 @@ Alert if there are errors in the snclient log file: func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ []Argument) (*CheckResult, error) { c.snc = snc - enabled, _, _ := snc.config.Section("/modules").GetBool("CheckLogFile") - if !enabled { - return nil, fmt.Errorf("module CheckLogFile is not enabled in /modules section") - } - - c.FilePathPatterns = append(c.FilePathPatterns, strings.Split(c.FilePathPatternsCS, ",")...) - if len(c.FilePathPatterns) == 0 { - return nil, fmt.Errorf("no file defined") - } - - patterns := make(map[string]*regexp.Regexp, len(c.LabelPattern)) - for _, labelPattern := range c.LabelPattern { - parts := strings.SplitN(labelPattern, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("the label pattern is in the wrong format") - } - var err error - patterns[parts[0]], err = regexp.Compile(parts[1]) - if err != nil { - return nil, fmt.Errorf("could not compile regex from pattern: %s", err.Error()) - } + patterns, allowedPattern, err := c.processArguments() + if err != nil { + return nil, err } - // patterns are for the file names/paths, not file contents! - allowedPattern := c.getAllowedPattern() - totalLineIndexedCount := 0 checkedFilesWithMatchedEntries := make(map[string]int, 0) @@ -166,17 +147,51 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ "file_counts": c.buildFileCountsDetailString(checkedFilesWithMatchedEntries), } - if len(checkedFilesWithMatchedEntries) == 0 { - check.emptySyntax = fmt.Sprintf("%%(status) - No files found to search lines in, search paths: '%s' ", strings.Join(c.FilePathPatterns, ",")) - } else if len(check.listData) == 0 { + switch { + case len(checkedFilesWithMatchedEntries) == 0: + check.emptySyntax = fmt.Sprintf("%%(status) - No files found to search lines in, search paths: '%s' ", strings.Join(c.FilePathPatterns, " , ")) + case len(check.listData) == 0: check.emptyState = CheckExitOK check.emptyStateSet = true check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) + default: + check.okSyntax = fmt.Sprintf("%%(status) - All %%(count) / %%(total) Lines OK (%s)", check.details["file_counts"]) } return check.Finalize() } +func (c *CheckLogFile) processArguments() (patterns map[string]*regexp.Regexp, allowedPattern []string, err error) { + enabled, _, _ := c.snc.config.Section("/modules").GetBool("CheckLogFile") + if !enabled { + return nil, nil, fmt.Errorf("module CheckLogFile is not enabled in /modules section") + } + + if c.FilePathPatternsCS != "" { + c.FilePathPatterns = append(c.FilePathPatterns, strings.Split(c.FilePathPatternsCS, ",")...) + } + if len(c.FilePathPatterns) == 0 { + return nil, nil, fmt.Errorf("no file defined, specify some file path patterns") + } + + patterns = make(map[string]*regexp.Regexp, len(c.LabelPattern)) + for _, labelPattern := range c.LabelPattern { + parts := strings.SplitN(labelPattern, ":", 2) + if len(parts) != 2 { + return nil, nil, fmt.Errorf("the label pattern is in the wrong format") + } + var err error + patterns[parts[0]], err = regexp.Compile(parts[1]) + if err != nil { + return nil, nil, fmt.Errorf("could not compile regex from pattern: %s", err.Error()) + } + } + + allowedPattern = c.getAllowedPattern() + + return patterns, allowedPattern, nil +} + func (c *CheckLogFile) buildFileCountsDetailString(checkedFilesWithMatchedEntries map[string]int) (fileCountDetails string) { type kv struct { file string @@ -290,7 +305,7 @@ func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[str } if !check.MatchMapCondition(check.filter, entry, false) { - log.Tracef("file: %s , line : %s, did not match the filter set in the check, not ading to check.listData", fileName, line) + log.Tracef("file: %s , line : %s, did not match the filter set in the check, not adding to check.listData", fileName, line) continue } diff --git a/pkg/snclient/check_logfile_test.go b/pkg/snclient/check_logfile_test.go index b9f4fe43..f02b30ee 100644 --- a/pkg/snclient/check_logfile_test.go +++ b/pkg/snclient/check_logfile_test.go @@ -24,6 +24,15 @@ func TestCheckLogFileDisabled(t *testing.T) { StopTestAgent(t, snc) } +func TestCheckLogFileNoArguments(t *testing.T) { + snc := StartTestAgent(t, testLogfileConfig) + res := snc.RunCheck("check_logfile", []string{}) + assert.Equalf(t, CheckExitUnknown, res.State, "state UNKNOWN") + assert.Contains(t, string(res.BuildPluginOutput()), "UNKNOWN - no file defined") + + StopTestAgent(t, snc) +} + func TestCheckLogFile(t *testing.T) { snc := StartTestAgent(t, testLogfileConfig) res := snc.RunCheck("check_logfile", []string{"file=./t/test.log"}) @@ -136,3 +145,13 @@ func TestCheckLogFileFileDoesNotExist(t *testing.T) { StopTestAgent(t, snc) } + +func TestCheckLogFileLineExistsOnTheFirstFile(t *testing.T) { + snc := StartTestAgent(t, testLogfileConfig) + + res := snc.RunCheck("check_logfile", []string{"files=./t/test*", "filter='line LIKE testUser2'"}) + assert.Equalf(t, CheckExitOK, res.State, "state should be OK") + assert.Contains(t, string(res.BuildPluginOutput()), "OK - All 1 / 16 Lines OK (t/test.log: 0, t/test2.log: 1)") + + StopTestAgent(t, snc) +} diff --git a/pkg/snclient/t/test.log b/pkg/snclient/t/test.log index 107f8f79..426a8a10 100644 --- a/pkg/snclient/t/test.log +++ b/pkg/snclient/t/test.log @@ -1,8 +1,8 @@ 2023-04-01 12:00:00;INFO;Application started successfully -2023-04-01 12:00:01;DEBUG;User logged in: username = testUser +2023-04-01 12:00:01;DEBUG;User logged in: username = testUser1 2023-04-01 12:00:02;INFO;User interface initialized 2023-04-01 12:00:03;WARNING;Potential issue detected in background process 2023-04-01 12:00:04;ERROR;Failed to connect to database: Connection refused 2023-04-01 12:00:05;INFO;Attempting to reconnect to database 2023-04-01 12:00:06;DEBUG;Reconnection to database successful -2023-04-01 12:00:07;INFO;System check completed successfully \ No newline at end of file +2023-04-01 12:00:07;INFO;System check completed successfully diff --git a/pkg/snclient/t/test2.log b/pkg/snclient/t/test2.log index 107f8f79..e7d03b68 100644 --- a/pkg/snclient/t/test2.log +++ b/pkg/snclient/t/test2.log @@ -1,8 +1,8 @@ 2023-04-01 12:00:00;INFO;Application started successfully -2023-04-01 12:00:01;DEBUG;User logged in: username = testUser +2023-04-01 12:00:01;DEBUG;User logged in: username = testUser2 2023-04-01 12:00:02;INFO;User interface initialized 2023-04-01 12:00:03;WARNING;Potential issue detected in background process 2023-04-01 12:00:04;ERROR;Failed to connect to database: Connection refused 2023-04-01 12:00:05;INFO;Attempting to reconnect to database 2023-04-01 12:00:06;DEBUG;Reconnection to database successful -2023-04-01 12:00:07;INFO;System check completed successfully \ No newline at end of file +2023-04-01 12:00:07;INFO;System check completed successfully From 3551ac728803417c603cff18eb6ce31214540089 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Wed, 8 Jul 2026 18:38:55 +0200 Subject: [PATCH 05/13] adjust the file-count check for windows platform it uses backward slashes, so fully typing the detail prevents a match --- pkg/snclient/check_logfile_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/snclient/check_logfile_test.go b/pkg/snclient/check_logfile_test.go index f02b30ee..54324c16 100644 --- a/pkg/snclient/check_logfile_test.go +++ b/pkg/snclient/check_logfile_test.go @@ -151,7 +151,8 @@ func TestCheckLogFileLineExistsOnTheFirstFile(t *testing.T) { res := snc.RunCheck("check_logfile", []string{"files=./t/test*", "filter='line LIKE testUser2'"}) assert.Equalf(t, CheckExitOK, res.State, "state should be OK") - assert.Contains(t, string(res.BuildPluginOutput()), "OK - All 1 / 16 Lines OK (t/test.log: 0, t/test2.log: 1)") + assert.Contains(t, string(res.BuildPluginOutput()), "test.log: 0") + assert.Contains(t, string(res.BuildPluginOutput()), "test2.log: 1") StopTestAgent(t, snc) } From b5582b65924e6ab05fb7cf4a42275eaacac8e05c Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Thu, 9 Jul 2026 16:22:28 +0200 Subject: [PATCH 06/13] check_logfile: automatically add count metric if its used in threshold --- pkg/snclient/check_logfile.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 188dde15..f15c1202 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -147,6 +147,11 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ "file_counts": c.buildFileCountsDetailString(checkedFilesWithMatchedEntries), } + if check.HasThreshold("count") { + check.addCountMetrics = true + check.addCountMetricsToFront = true + } + switch { case len(checkedFilesWithMatchedEntries) == 0: check.emptySyntax = fmt.Sprintf("%%(status) - No files found to search lines in, search paths: '%s' ", strings.Join(c.FilePathPatterns, " , ")) From 51f69963b7f38ca11e2d0bee89899b356083cf9d Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Thu, 9 Jul 2026 16:49:13 +0200 Subject: [PATCH 07/13] check_logfile: adjust ok and top syntax to a be more understandable --- docs/checks/commands/check_logfile.md | 14 +++++++------- pkg/snclient/check_logfile.go | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/checks/commands/check_logfile.md b/docs/checks/commands/check_logfile.md index a0db1f3e..da2733ca 100644 --- a/docs/checks/commands/check_logfile.md +++ b/docs/checks/commands/check_logfile.md @@ -56,13 +56,13 @@ Naemon Config ## Argument Defaults -| Argument | Default Value | -| ------------- | ---------------------------------------------------------------------- | -| empty-state | 3 (UNKNOWN) | -| empty-syntax | %(status) - No files found | -| top-syntax | %(status) - %(problem_count)/%(count) lines (%(count)) %(problem_list) | -| ok-syntax | %(status) - All %(count) / %(total) Lines OK | -| detail-syntax | %(line \| chomp \| cut=200) | +| Argument | Default Value | +| ------------- | --------------------------------------------------- | +| empty-state | 3 (UNKNOWN) | +| empty-syntax | %(status) - No files found | +| top-syntax | %(status) - %(problem_count)/%(count) line(s) found | +| ok-syntax | %(status) - %(count) line(s) found | +| detail-syntax | %(line \| chomp \| cut=200) | ## Check Specific Arguments diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index f15c1202..8cd6c905 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -66,8 +66,8 @@ func (c *CheckLogFile) Build() *CheckData { `, detailSyntax: "%(line | chomp | cut=200)", // cut after 200 chars listCombine: "\n", - okSyntax: "%(status) - All %(count) / %(total) Lines OK", - topSyntax: "%(status) - %(problem_count)/%(count) lines (%(count)) %(problem_list)", + okSyntax: "%(status) - %(count) line(s) found", + topSyntax: "%(status) - %(problem_count)/%(count) line(s) found", emptySyntax: "%(status) - No files found", emptyState: CheckExitUnknown, args: map[string]CheckArgument{ @@ -160,7 +160,7 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ check.emptyStateSet = true check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) default: - check.okSyntax = fmt.Sprintf("%%(status) - All %%(count) / %%(total) Lines OK (%s)", check.details["file_counts"]) + check.okSyntax = fmt.Sprintf("%%(status) - All %%(count) lines found") } return check.Finalize() From 1eeab5f424d67ae834665f1c8a03fc8ec08f8dca Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 10 Jul 2026 10:30:55 +0200 Subject: [PATCH 08/13] clarify "lines" -> "line(s)" --- pkg/snclient/check_logfile.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 8cd6c905..a60c828a 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -160,7 +160,7 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ check.emptyStateSet = true check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) default: - check.okSyntax = fmt.Sprintf("%%(status) - All %%(count) lines found") + check.okSyntax = fmt.Sprintf("%%(status) - All %%(count) line(s) found") } return check.Finalize() From 582f33d3142e3e73f21557208ebeef51c95780fa Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 10 Jul 2026 10:37:30 +0200 Subject: [PATCH 09/13] remove redundant assignment to okSyntax and fix failing tests --- pkg/snclient/check_logfile.go | 2 -- pkg/snclient/check_logfile_test.go | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index a60c828a..52f360ba 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -159,8 +159,6 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ check.emptyState = CheckExitOK check.emptyStateSet = true check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) - default: - check.okSyntax = fmt.Sprintf("%%(status) - All %%(count) line(s) found") } return check.Finalize() diff --git a/pkg/snclient/check_logfile_test.go b/pkg/snclient/check_logfile_test.go index 54324c16..8392f0d8 100644 --- a/pkg/snclient/check_logfile_test.go +++ b/pkg/snclient/check_logfile_test.go @@ -41,7 +41,7 @@ func TestCheckLogFile(t *testing.T) { res = snc.RunCheck("check_logfile", []string{"files=./t/test*"}) assert.Equalf(t, CheckExitOK, res.State, "state OK") - assert.Contains(t, string(res.BuildPluginOutput()), "All 16") + assert.Contains(t, string(res.BuildPluginOutput()), "16") StopTestAgent(t, snc) } @@ -91,7 +91,7 @@ func TestCheckLogFileOKPatternResetsErrors(t *testing.T) { res := snc.RunCheck("check_logfile", []string{"files=./t/test*", "warn=line LIKE ERROR", "ok=line LIKE 'System check completed successfully'"}) assert.Equalf(t, CheckExitOK, res.State, "state OK") - assert.Contains(t, string(res.BuildPluginOutput()), "All 16") + assert.Contains(t, string(res.BuildPluginOutput()), "16") StopTestAgent(t, snc) } @@ -151,8 +151,6 @@ func TestCheckLogFileLineExistsOnTheFirstFile(t *testing.T) { res := snc.RunCheck("check_logfile", []string{"files=./t/test*", "filter='line LIKE testUser2'"}) assert.Equalf(t, CheckExitOK, res.State, "state should be OK") - assert.Contains(t, string(res.BuildPluginOutput()), "test.log: 0") - assert.Contains(t, string(res.BuildPluginOutput()), "test2.log: 1") StopTestAgent(t, snc) } From 5d6a76d77ebe46f2374cda97940a4562720ebda7 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 10 Jul 2026 10:41:18 +0200 Subject: [PATCH 10/13] adjust example output after syntax changes --- docs/checks/commands/check_logfile.md | 6 +++++- pkg/snclient/check_logfile.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/checks/commands/check_logfile.md b/docs/checks/commands/check_logfile.md index da2733ca..c6a46310 100644 --- a/docs/checks/commands/check_logfile.md +++ b/docs/checks/commands/check_logfile.md @@ -36,7 +36,11 @@ Checks logfiles or any other text format file for errors or other general patter Alert if there are errors in the snclient log file: check_files files=/var/log/snclient/snclient.log 'warn=line like Warn' 'crit=line like Error'" - OK - All 1787 / 1787 Lines OK + OK - 134 line(s) found + +Non-OK output example + check_files files=/var/log/snclient/snclient.log 'warn=line like Warn' 'crit=line like Error'" + CRITICAL - 23/548 line(s) found ### Example using NRPE and Naemon diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 52f360ba..8f21766f 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -91,7 +91,11 @@ func (c *CheckLogFile) Build() *CheckData { Alert if there are errors in the snclient log file: check_files files=/var/log/snclient/snclient.log 'warn=line like Warn' 'crit=line like Error'" - OK - All 1787 / 1787 Lines OK + OK - 134 line(s) found + +Non-OK output example + check_files files=/var/log/snclient/snclient.log 'warn=line like Warn' 'crit=line like Error'" + CRITICAL - 23/548 line(s) found `, exampleArgs: `'files=/var/log/snclient/snclient.log' 'warn=line like Warn'`, } From 9e685f87dedd5fc436809c9ddec095c4cc6ce171 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 10 Jul 2026 13:59:09 +0200 Subject: [PATCH 11/13] check_logfile: adjust output even more additionally, detect IsPermission error that comes off from addFile - it already includes the filePath in its os.Open() error. No need to add file path for a second time when wrapping the error in fmt.Errorf --- pkg/snclient/check_logfile.go | 19 +++++++++++++------ pkg/snclient/check_logfile_test.go | 6 +++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 8f21766f..9bf056dd 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -67,7 +67,7 @@ func (c *CheckLogFile) Build() *CheckData { detailSyntax: "%(line | chomp | cut=200)", // cut after 200 chars listCombine: "\n", okSyntax: "%(status) - %(count) line(s) found", - topSyntax: "%(status) - %(problem_count)/%(count) line(s) found", + topSyntax: "%(status) - %(problem_count)/%(count) line(s) found \n%(problem_list)", emptySyntax: "%(status) - No files found", emptyState: CheckExitUnknown, args: map[string]CheckArgument{ @@ -134,7 +134,13 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ log.Debugf("check_logfile adding file: %s", fileName) entries, lineIndex, err := c.addFile(fileName, check, patterns) if err != nil { - return nil, fmt.Errorf("error for file %s, error was: %s", fileName, err.Error()) + switch { + case os.IsPermission(err): + // permission errors already include the filename in them + return nil, fmt.Errorf("failed to add file: %s", err.Error()) + default: + return nil, fmt.Errorf("failed to add file: %s , %s", fileName, err.Error()) + } } log.Debugf("check_logfile file: %s | returned entries: %v | lines indexed: %d", fileName, entries, lineIndex) @@ -162,7 +168,7 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ case len(check.listData) == 0: check.emptyState = CheckExitOK check.emptyStateSet = true - check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found in files (%s)", check.details["file_counts"]) + check.emptySyntax = fmt.Sprintf("%%(status) - No matching lines found") } return check.Finalize() @@ -178,7 +184,7 @@ func (c *CheckLogFile) processArguments() (patterns map[string]*regexp.Regexp, a c.FilePathPatterns = append(c.FilePathPatterns, strings.Split(c.FilePathPatternsCS, ",")...) } if len(c.FilePathPatterns) == 0 { - return nil, nil, fmt.Errorf("no file defined, specify some file path patterns") + return nil, nil, fmt.Errorf("no file specified") } patterns = make(map[string]*regexp.Regexp, len(c.LabelPattern)) @@ -236,16 +242,17 @@ func (c *CheckLogFile) buildFileCountsDetailString(checkedFilesWithMatchedEntrie return fileCountDetails } +//nolint:wrapcheck // need to preserve the type of error that comes of from os.Open. The caller then checks the type. If we wrapped it, it would be a generic error out of fmt.Errorf func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[string]*regexp.Regexp) (entries []map[string]string, lineIndex int, err error) { file, err := os.Open(fileName) if err != nil { - return entries, 0, fmt.Errorf("could not open file: %s error was: %s", fileName, err.Error()) + return entries, 0, err } defer file.Close() info, err := file.Stat() if err != nil { - return entries, 0, fmt.Errorf("could not stat file %s: %s", fileName, err.Error()) + return entries, 0, fmt.Errorf("getting file stats failed with error: %s", err.Error()) } currentInode := getInode(fileName) diff --git a/pkg/snclient/check_logfile_test.go b/pkg/snclient/check_logfile_test.go index 8392f0d8..d662ffd0 100644 --- a/pkg/snclient/check_logfile_test.go +++ b/pkg/snclient/check_logfile_test.go @@ -28,7 +28,7 @@ func TestCheckLogFileNoArguments(t *testing.T) { snc := StartTestAgent(t, testLogfileConfig) res := snc.RunCheck("check_logfile", []string{}) assert.Equalf(t, CheckExitUnknown, res.State, "state UNKNOWN") - assert.Contains(t, string(res.BuildPluginOutput()), "UNKNOWN - no file defined") + assert.Contains(t, string(res.BuildPluginOutput()), "UNKNOWN - no file specified") StopTestAgent(t, snc) } @@ -61,7 +61,7 @@ func TestCheckLogFilePathWildCards(t *testing.T) { res = snc.RunCheck("check_logfile", []string{"files=./t/test*", "warn=line LIKE WARNING"}) assert.Equalf(t, CheckExitOK, res.State, "state OK") - assert.Contains(t, string(res.BuildPluginOutput()), "OK - No matching lines found in files ") + assert.Contains(t, string(res.BuildPluginOutput()), "OK - No matching lines found") StopTestAgent(t, snc) } @@ -131,7 +131,7 @@ func TestCheckLogFileFileExistsButHasNoLines(t *testing.T) { res := snc.RunCheck("check_logfile", []string{"files=./t/test*", "filter=line LIKE this-pattern-does-not-exist-in-the-test-files", "show-all"}) assert.Equalf(t, CheckExitOK, res.State, "state should be OK") - assert.Contains(t, string(res.BuildPluginOutput()), "OK - No matching lines found in files") + assert.Contains(t, string(res.BuildPluginOutput()), "OK - No matching lines") StopTestAgent(t, snc) } From 15ba7e25914f43b896ef23bf66d3017a8212356a Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 10 Jul 2026 14:19:13 +0200 Subject: [PATCH 12/13] check_logfile: regenerate docs after syntax changes --- docs/checks/commands/check_logfile.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/checks/commands/check_logfile.md b/docs/checks/commands/check_logfile.md index c6a46310..30573aec 100644 --- a/docs/checks/commands/check_logfile.md +++ b/docs/checks/commands/check_logfile.md @@ -60,13 +60,13 @@ Naemon Config ## Argument Defaults -| Argument | Default Value | -| ------------- | --------------------------------------------------- | -| empty-state | 3 (UNKNOWN) | -| empty-syntax | %(status) - No files found | -| top-syntax | %(status) - %(problem_count)/%(count) line(s) found | -| ok-syntax | %(status) - %(count) line(s) found | -| detail-syntax | %(line \| chomp \| cut=200) | +| Argument | Default Value | +| ------------- | --------------------------------------------------------------------- | +| empty-state | 3 (UNKNOWN) | +| empty-syntax | %(status) - No files found | +| top-syntax | %(status) - %(problem_count)/%(count) line(s) found \n%(problem_list) | +| ok-syntax | %(status) - %(count) line(s) found | +| detail-syntax | %(line \| chomp \| cut=200) | ## Check Specific Arguments From b9cb8dec69e688b5d0e1155c29e217a59cb9d904 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Fri, 10 Jul 2026 17:59:13 +0200 Subject: [PATCH 13/13] check_logfile: use %w when formatting errors --- pkg/snclient/check_logfile.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/snclient/check_logfile.go b/pkg/snclient/check_logfile.go index 9bf056dd..3a73ffd2 100644 --- a/pkg/snclient/check_logfile.go +++ b/pkg/snclient/check_logfile.go @@ -121,7 +121,7 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ lineIndexedInThisFilePattern := 0 files, err := filepath.Glob(fileName) if err != nil { - return nil, fmt.Errorf("could not get files for pattern %s, error was: %s", fileName, err.Error()) + return nil, fmt.Errorf("could not get files for pattern %s, error was: %w", fileName, err) } for _, fileName := range files { @@ -137,9 +137,9 @@ func (c *CheckLogFile) Check(_ context.Context, snc *Agent, check *CheckData, _ switch { case os.IsPermission(err): // permission errors already include the filename in them - return nil, fmt.Errorf("failed to add file: %s", err.Error()) + return nil, fmt.Errorf("failed to add file: %w", err) default: - return nil, fmt.Errorf("failed to add file: %s , %s", fileName, err.Error()) + return nil, fmt.Errorf("failed to add file: %s , %w", fileName, err) } } log.Debugf("check_logfile file: %s | returned entries: %v | lines indexed: %d", fileName, entries, lineIndex) @@ -196,7 +196,7 @@ func (c *CheckLogFile) processArguments() (patterns map[string]*regexp.Regexp, a var err error patterns[parts[0]], err = regexp.Compile(parts[1]) if err != nil { - return nil, nil, fmt.Errorf("could not compile regex from pattern: %s", err.Error()) + return nil, nil, fmt.Errorf("could not compile regex from pattern: %w", err) } } @@ -252,7 +252,7 @@ func (c *CheckLogFile) addFile(fileName string, check *CheckData, labels map[str info, err := file.Stat() if err != nil { - return entries, 0, fmt.Errorf("getting file stats failed with error: %s", err.Error()) + return entries, 0, fmt.Errorf("getting file stats failed with error: %w", err) } currentInode := getInode(fileName) @@ -345,7 +345,7 @@ func (c *CheckLogFile) getStartOffset(fileName string, currentSize int64, curren // user provided an offset string, attempt to parse it. startOffset, err := convert.Int64E(c.Offset) if err != nil { - return 0, fmt.Errorf("invalid offset value '%s' provided: %s", c.Offset, err.Error()) + return 0, fmt.Errorf("invalid offset value '%s' provided: %w", c.Offset, err) } if startOffset < 0 { return 0, fmt.Errorf("offset cannot be negative: %d", startOffset)