One of my passions is DX, and while I’m not always getting it right, the enjoyment is there to make people’s lives a little bit more productive.
Some context#
Coming from a predominantly PHP background, at least in my bubble, having “pretty” error messages when doing development is pretty much a given.
Regardless of the framework you might be using all of them use php.net/set_error_handler in one way shape or form.
For example, using nunomaduro/collision is at easy as doing:
1
2
3
4
5
| <?php
require 'vendor/autoload.php';
(new \NunoMaduro\Collision\Provider)->register();
|
Using symfony/error-handler is also easy peasy:
1
2
3
4
5
6
7
8
9
| <?php
require 'vendor/autoload.php';
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\ErrorHandler\ErrorHandler;
use Symfony\Component\ErrorHandler\DebugClassLoader;
Debug::enable();
|
The Problem#
Although the Go compiler is generally pretty good on catching errors, you won’t always get around runtime errors. That brings me to the fact that in Go, panics are just ugly (personal preference).
Since google, bing, ddg and other lovely search engines let me down in finding an equivalent in Go that I can use as a defered panic recover system, I did what everybody would do, and asked my new ai overlords lovely randomly selected large language model (LLM) (in my case phind) to whip me up something that gets closer to the PHP Examples above.
After some back and forth, we got to this lovely sample, which works awesome as a POC.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
| package errorhandler
import (
"bufio"
"fmt"
"os"
"runtime"
"runtime/debug"
"strconv"
"strings"
)
const (
red = "\033[31m"
green = "\033[32m"
yellow = "\033[33m"
cyan = "\033[36m"
reset = "\033[0m"
)
func CustomPanicHandler() {
if r := recover(); r != nil {
// Use colored output for the panic message and add an error type
fmt.Printf("\n%sError: %s%s\n\n", red, r, reset)
stackBytes := debug.Stack()
stack := string(stackBytes)
stackLines := strings.Split(stack, "\n")
var stackBuffer []string
var lastRealFilename string
var lastRealLineNum int
for i, line := range stackLines {
if i == 0 {
continue
} else if strings.Contains(line, "runtime/debug.Stack()") {
continue
} else if strings.Contains(line, GetFunctionName()) {
continue
}
if i%2 == 0 {
stackBuffer = append(stackBuffer, fmt.Sprintf(" %s%s%s", cyan, line, reset))
} else {
stackBuffer = append(stackBuffer, line)
}
fileAndLine := strings.Split(line, ":")
if len(fileAndLine) < 2 {
continue
}
fileName := strings.TrimSpace(fileAndLine[0])
lineNum, err := strconv.Atoi(strings.Split(fileAndLine[1], " ")[0])
if err != nil {
continue
}
lastRealFilename = fileName
lastRealLineNum = lineNum
}
// Display the offending line only for the last real file found
if lastRealFilename != "" {
fileContent, err := readFileLines(lastRealFilename)
if err != nil {
fmt.Println("Error reading file:", err)
} else {
printOffendingLine(lastRealFilename, fileContent, lastRealLineNum)
}
}
// Print the buffered stack trace
fmt.Println("\nPanic occurred:")
for _, line := range stackBuffer {
fmt.Println(line)
}
// Exit the program with a non-zero status code
os.Exit(1)
}
}
func readFileLines(filename string) ([]string, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
return lines, nil
}
func printOffendingLine(filename string, fileContent []string, lineNum int) {
startLine := lineNum - 3
if startLine < 0 {
startLine = 0
}
endLine := lineNum + 1
if endLine >= len(fileContent) {
endLine = len(fileContent) - 1
}
fmt.Printf("\n%sOffending line: %s%s:%d%s\n", yellow, green, filename, lineNum, reset)
for idx, content := range fileContent[startLine:endLine] {
if idx+startLine == lineNum-1 {
fmt.Printf(" %s%d: %s%s\n", yellow, idx+startLine+1, content, reset)
} else {
fmt.Printf(" %d: %s\n", idx+startLine+1, content)
}
}
}
func GetFunctionName() string {
pc, _, _, _ := runtime.Caller(1)
return runtime.FuncForPC(pc).Name()
}
|
The problem is that the above code didn’t work correctly when trying to implement a simple http handler, reason being panic / recover happen per goroutine, and there’s no generic catch all setup.
Because of that, even though you could make something it will be a pain in the ass to implement since you need to manually wrap each goroutine with the custom panic handler and it won’t catch panics in goroutines created by third-party packages or the Go standard library.
But Alex, why didn’t you just use panicparse ?
Answer: I did, but I wanted to understand why panicparse pipes the Go stacktrace to panicparse, and it’s not implemented as an SDK.
Perfect solution?#
Generally in life, there are no perfect solutions, just perfect compromises :)
At the end of all, my solution for having live reload and nice panics, that I can click in vscode to link to the file that caused the problem, was to use the following setup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| FROM golang:1.20-alpine AS api
RUN apk add --no-cache bash
RUN apk add --no-cache coreutils
WORKDIR /go/src/app
# Install Air and panicparse
RUN go install github.com/cosmtrek/air@latest
RUN go install github.com/maruel/panicparse/v2@latest
# Copy Go modules and dependencies to image
COPY go.mod ./
# Download Go modules and dependencies
RUN go mod download
# Copy directory files
COPY . ./
CMD ["/bin/bash", "scripts/dev.sh"]
|
And have the following startup script:
1
2
3
4
5
6
7
| #!/bin/bash
export GOTRACEBACK=all
stdbuf -oL air -c .air.toml 2>&1 | \
stdbuf -oL panicparse -force-color -rel-path | \
stdbuf -oL sed 's/\(app\/[^ ]*\)/\1 /g' | \
stdbuf -oL sed 's/\(app\/\)//g'
|
For the reason on why I used cosmtrek/air, and not CompileDaemon, was pretty easy, but at the same time pretty dumb, I couldn’t make CompileDaemon send the correct SIGTERM signal to my app in order to gracefully stop the net/http server.
Sed is there purely for making the links clickable in vscode, since otherwise panicparse keeps app/ at the start of the path even if it’s in -rep-path
mode.
You do need to use stdbuf -oL since otherwise sed does block buffered, not line buffered, and docker's default log output doesn't like it.
This brings us from the generic panic handler:
To this output panic parse output: