Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add panic parser #18

Merged
merged 4 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading