From 3992c8f4e3ccd62072d9b8ce77f2ee91aa70b907 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Mon, 24 Jun 2024 15:34:18 +0200 Subject: [PATCH 1/4] add panic parser --- .golangci.yml | 6 +- go.mod | 2 +- middlewares/panicparse.go | 112 +++++++++++++++++++++++++++++++++ middlewares/panicparse_test.go | 85 +++++++++++++++++++++++++ middlewares/recoverer.go | 53 +++++++++++++++- 5 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 middlewares/panicparse.go create mode 100644 middlewares/panicparse_test.go diff --git a/.golangci.yml b/.golangci.yml index 3e56b2f..8798e2e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -202,7 +202,7 @@ linters-settings: gosimple: # Select the Go version to target. The default is '1.13'. - go: "1.16" + go: "1.18" # https://staticcheck.io/docs/options#checks checks: [ "all" ] @@ -230,7 +230,7 @@ linters-settings: staticcheck: # Select the Go version to target. The default is '1.13'. - go: "1.16" + go: "1.18" # https://staticcheck.io/docs/options#checks checks: [ "all" ] @@ -273,7 +273,7 @@ linters-settings: unused: # Select the Go version to target. The default is '1.13'. - go: "1.16" + go: "1.18" linters: diff --git a/go.mod b/go.mod index aadef7b..ee1babf 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/infiniteloopcloud/go -go 1.17 +go 1.18 require ( github.com/aws/aws-sdk-go-v2 v1.17.3 diff --git a/middlewares/panicparse.go b/middlewares/panicparse.go new file mode 100644 index 0000000..5373dbd --- /dev/null +++ b/middlewares/panicparse.go @@ -0,0 +1,112 @@ +package middlewares + +import ( + "fmt" + "regexp" + "runtime" + "strconv" + "strings" +) + +var fileDetails = regexp.MustCompile(`^(/([\w-._]+/)*([\w-]+\.[a-z]+)):(\d+)\s(\+0x[0-9a-fA-F]+$)`) +var tabsNewlinesRegexp = regexp.MustCompile(`[\t\n]+`) + +type stackFlags struct { + Vendor bool + Builtin bool +} +type stackLine struct { + FunctionName string + FilePath string + FilePathShort string + LineNumber int + StackPosition string + Flags stackFlags +} + +func (sl stackLine) String() string { + return fmt.Sprintf("%s:%d:%s", sl.FilePathShort, sl.LineNumber, sl.FunctionName) +} + +type panicParser struct{} + +func (p panicParser) Parse(stack []byte) ([]stackLine, error) { + str := strings.TrimSpace(string(stack)) + // cut the header + _, str, _ = strings.Cut(str, "\n") + // replace new lines with tabs + str = strings.ReplaceAll(str, "\n", "\t") + // replace tab duplications with single tabs + str = tabsNewlinesRegexp.ReplaceAllString(str, "\t") + + // nolint: prealloc + var result []stackLine + var tabCounter int + var latestTabIndex int + for i, r := range []rune(str) { + if r != '\t' { + // skip chars until reaching a tab + continue + } + tabCounter++ + if tabCounter%2 != 0 { + // every second tab matters, odd tabs will be skipped + continue + } + + // take the part of the string between the even tabs + line := p.substring(str, latestTabIndex, i) + + functionNameParams, fileData, found := strings.Cut(line, "\t") + if !found { + return nil, fmt.Errorf("error separating function name from file details: %s", line) + } + + // remove last (...) + functionName, _, found := p.cutLast(functionNameParams, "(") + if !found { + return nil, fmt.Errorf("error cutting params from the func definition: %s", functionNameParams) + } + + fileDetailsRegexResult := fileDetails.FindAllStringSubmatch(fileData, -1) + if len(fileDetailsRegexResult) == 0 || len(fileDetailsRegexResult[0]) != 6 { + return nil, fmt.Errorf("error parsing file details of the stack line: %s", fileData) + } + + lineNum, err := strconv.Atoi(fileDetailsRegexResult[0][4]) + if err != nil { + return nil, fmt.Errorf("error parsing line number: %w", err) + } + + groot := runtime.GOROOT() + result = append(result, stackLine{ + FunctionName: functionName + "(...)", + FilePath: fileDetailsRegexResult[0][1], + FilePathShort: fileDetailsRegexResult[0][2] + fileDetailsRegexResult[0][3], + LineNumber: lineNum, + StackPosition: fileDetailsRegexResult[0][5], + Flags: stackFlags{ + Vendor: strings.Contains(fileDetailsRegexResult[0][1], "/vendor/"), + Builtin: strings.Contains(fileDetailsRegexResult[0][1], groot), + }, + }) + latestTabIndex = i + } + + return result, nil +} + +func (panicParser) substring(str string, i, j int) string { + if i > 0 { + i += 1 + } + return str[i:j] +} + +func (panicParser) cutLast(str, sep string) (string, string, bool) { + idx := strings.LastIndex(str, sep) + if idx < 0 { + return str, "", false + } + return str[:idx], str[idx+1:], true +} diff --git a/middlewares/panicparse_test.go b/middlewares/panicparse_test.go new file mode 100644 index 0000000..4e270d5 --- /dev/null +++ b/middlewares/panicparse_test.go @@ -0,0 +1,85 @@ +package middlewares + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "runtime/debug" + "strconv" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type panicParserWrtier struct { + b *bytes.Buffer +} + +func (w *panicParserWrtier) Write(b []byte) (n int, err error) { + w.b.Write(b) + w.b.WriteByte(byte('\n')) + return len(b) + 1, nil +} + +func TestPanicParser_Parse(t *testing.T) { + r := chi.NewRouter() + + var parsed []stackLine + oldRecovererErrorWriter := recovererErrorWriter + defer func() { recovererErrorWriter = oldRecovererErrorWriter }() + w := panicParserWrtier{b: bytes.NewBuffer(nil)} + recovererErrorWriter = &w + + r.Use(Recoverer) + SetPrintPrettyStack(func(ctx context.Context, rvr interface{}) { + debugStack := debug.Stack() + var err error + parsed, err = panicParser{}.Parse(debugStack) + if err != nil { + os.Stderr.Write(debugStack) + return + } + multilinePrettyPrint(ctx, rvr, parsed) + }) + r.Get("/", panicingHandler) + + ts := httptest.NewServer(r) + defer ts.Close() + + res, _ := testRequest(t, ts, "GET", "/", nil) + assertEqual(t, res.StatusCode, http.StatusInternalServerError) + + assert.Len(t, parsed, 13) + + var latestPanicID string + for _, line := range strings.Split(w.b.String(), "\n") { + if line == "" { + continue + } + var logLine map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &logLine)) + panicID, ok := logLine["panic_id"] + require.True(t, ok) + if latestPanicID == "" { + // nolint: errcheck + latestPanicID = panicID.(string) + } else { + assert.Equal(t, latestPanicID, panicID) + } + lineNum, hasLineField := logLine["line_number"] + require.True(t, hasLineField) + // nolint: errcheck + _, err := strconv.Atoi(lineNum.(string)) + require.NoError(t, err) + _, hasFnName := logLine["function_name"] + require.True(t, hasFnName) + _, hasFilePath := logLine["file_path"] + require.True(t, hasFilePath) + } +} diff --git a/middlewares/recoverer.go b/middlewares/recoverer.go index 17d7fe1..9cd63c2 100644 --- a/middlewares/recoverer.go +++ b/middlewares/recoverer.go @@ -12,9 +12,12 @@ import ( "net/http" "os" "runtime/debug" + "slices" + "strconv" "strings" "github.com/go-chi/chi/v5/middleware" + "github.com/google/uuid" "github.com/infiniteloopcloud/log" ) @@ -24,6 +27,8 @@ type RecoverParserIface interface { var RecoverParser RecoverParserIface = prettyStack{} +type PrintPrettyStackFn func(ctx context.Context, rvr interface{}) + // Recoverer is a middleware that recovers from panics, logs the panic (and a // backtrace), and returns a HTTP 500 (Internal Server Error) status if // possible. Recoverer prints a request ID if one is provided. @@ -44,7 +49,8 @@ func Recoverer(next http.Handler) http.Handler { if logEntry != nil { logEntry.Panic(rvr, debug.Stack()) } else { - PrintPrettyStack(r.Context(), rvr) + // nolint:forbidigo + printPrettyStackFn(r.Context(), rvr) } w.WriteHeader(http.StatusInternalServerError) @@ -60,6 +66,8 @@ func Recoverer(next http.Handler) http.Handler { // for ability to test the PrintPrettyStack function var recovererErrorWriter io.Writer = os.Stderr +var printPrettyStackFn = PrintPrettyStack + func PrintPrettyStack(ctx context.Context, rvr interface{}) { debugStack := debug.Stack() out, err := RecoverParser.Parse(ctx, debugStack, rvr) @@ -72,6 +80,44 @@ func PrintPrettyStack(ctx context.Context, rvr interface{}) { } } +func MultilinePrettyPrintStack(ctx context.Context, rvr interface{}) { + debugStack := debug.Stack() + parsed, err := panicParser{}.Parse(debugStack) + if err != nil { + os.Stderr.Write(debugStack) + return + } + multilinePrettyPrint(ctx, rvr, parsed) +} + +func multilinePrettyPrint(ctx context.Context, rvr interface{}, parsed []stackLine) { + slices.Reverse(parsed) + panicID := uuid.NewString() + for _, line := range parsed { + logLine := log.Parse(ctx, log.ErrorLevelString, "panic happen", + fmt.Errorf("panic happen: %v", rvr), + log.Field{ + Key: "panic_id", + Value: panicID, + }, + log.Field{ + Key: "function_name", + Value: line.FunctionName, + }, + log.Field{ + Key: "file_path", + Value: line.FilePath, + }, + log.Field{ + Key: "line_number", + Value: strconv.Itoa(line.LineNumber), + }, + ) + // nolint: errcheck + recovererErrorWriter.Write([]byte(logLine)) + } +} + func SetRecovererErrorWriter(w io.Writer) { recovererErrorWriter = w } @@ -80,6 +126,11 @@ func SetRecoverParser(p RecoverParserIface) { RecoverParser = p } +// nolint:forbidigo +func SetPrintPrettyStack(fn PrintPrettyStackFn) { + printPrettyStackFn = fn +} + type prettyStack struct { } From 041d7c59970cb047f9c2fa34e6a7e0192187c795 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Mon, 24 Jun 2024 17:43:34 +0200 Subject: [PATCH 2/4] update linter --- .github/workflows/golangci-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 8d60f7c..e9820b1 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -26,4 +26,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: - version: v1.50 + version: v1.55.1 From 40a8ff77c6cd2e9f06d69bfefd762c10ef3a7169 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Mon, 24 Jun 2024 17:44:55 +0200 Subject: [PATCH 3/4] update go --- .github/workflows/golangci-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index e9820b1..32853ea 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/setup-go@v3 with: - go-version: 1.19 + go-version: 1.21 - uses: actions/checkout@v3 - run: go mod tidy - run: go mod vendor From f8a3d7f174a9b1082a5fe2eb16273e76b82f3378 Mon Sep 17 00:00:00 2001 From: Robert Boros Date: Mon, 24 Jun 2024 21:41:56 +0200 Subject: [PATCH 4/4] fixing typo --- middlewares/panicparse_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/middlewares/panicparse_test.go b/middlewares/panicparse_test.go index 4e270d5..614d5ba 100644 --- a/middlewares/panicparse_test.go +++ b/middlewares/panicparse_test.go @@ -17,11 +17,11 @@ import ( "github.com/stretchr/testify/require" ) -type panicParserWrtier struct { +type panicParserWriter struct { b *bytes.Buffer } -func (w *panicParserWrtier) Write(b []byte) (n int, err error) { +func (w *panicParserWriter) Write(b []byte) (n int, err error) { w.b.Write(b) w.b.WriteByte(byte('\n')) return len(b) + 1, nil @@ -33,7 +33,7 @@ func TestPanicParser_Parse(t *testing.T) { var parsed []stackLine oldRecovererErrorWriter := recovererErrorWriter defer func() { recovererErrorWriter = oldRecovererErrorWriter }() - w := panicParserWrtier{b: bytes.NewBuffer(nil)} + w := panicParserWriter{b: bytes.NewBuffer(nil)} recovererErrorWriter = &w r.Use(Recoverer)