Skip to content

Commit

Permalink
Merge pull request #18 from infiniteloopcloud/parse-panic
Browse files Browse the repository at this point in the history
Add panic parser
  • Loading branch information
PumpkinSeed authored Jun 25, 2024
2 parents 4c9b773 + f8a3d7f commit 9d0c6d8
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 7 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ 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
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.50
version: v1.55.1
6 changes: 3 additions & 3 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]

Expand Down Expand Up @@ -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" ]

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
112 changes: 112 additions & 0 deletions middlewares/panicparse.go
Original file line number Diff line number Diff line change
@@ -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
}
85 changes: 85 additions & 0 deletions middlewares/panicparse_test.go
Original file line number Diff line number Diff line change
@@ -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 panicParserWriter struct {
b *bytes.Buffer
}

func (w *panicParserWriter) 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 := panicParserWriter{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)
}
}
53 changes: 52 additions & 1 deletion middlewares/recoverer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -80,6 +126,11 @@ func SetRecoverParser(p RecoverParserIface) {
RecoverParser = p
}

// nolint:forbidigo
func SetPrintPrettyStack(fn PrintPrettyStackFn) {
printPrettyStackFn = fn
}

type prettyStack struct {
}

Expand Down

0 comments on commit 9d0c6d8

Please sign in to comment.