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();

PHP Collision Error Handling

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();

PHP Symfony Error Handling

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).

Default Golang panic handler

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. Custom Golang panic handler

  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: Golang Default Panic

To this output panic parse output: Golang Panic Parse