A Go(lang) journey in live reload and pretty panics
Tags: go, beginners, livereload, debugging
Published: 2023-05-19 20:37:32

---
title: A Go(lang) journey in live reload and pretty panics
published: true
date: 2023-05-03 19:05:51 UTC
tags: golang, beginner, livereload, debugging
canonical_url: https://alexandrubucur.com/tech/2023/a-golang-journey-in-live-reload-and-pretty-panics/
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x5rcldyieiq83tz8b61i.png
---

> <a href="#perfect-solution">TLDR: Use docker + cosmtrek/air + 

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 ](https://www.php.net/set_error_handler) in one way shape or form.

For example, using [nunomaduro/collision](https://github.com/nunomaduro/collision) is at easy as doing:

```php
<?php

require 'vendor/autoload.php';

(new \NunoMaduro\Collision\Provider)->register();
```

![PHP Collision Error Handling](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/27ckw8h2z8ll2uh4wwes.png)


Using [symphony/error-handler](https://github.com/symfony/error-handler) is also easy peasy:

```php
<?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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rfmnaha68p6mnla742p2.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nnol17r2s3ptu1fkzzs5.png)

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](https://www.phind.com/)) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mnohx6mqqbd0j2uklam2.png)

```go
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](https://github.com/maruel/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.

```docker
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:
```bash
#!/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](https://github.com/cosmtrek/air), and not [CompileDaemon](https://github.com/githubnemo/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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/230qt4xsjp3q2kdzm4yi.png)

To this output panic parse output:
![Golang Panic Parse](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ikc5lstem87von9csdwe.png)