Beyond Static Logging Part 2: Changing Go Log Levels at Runtime with Unix Signals

  This is part 2 of my Go logging series. If you haven’t read part 1, I recommend starting there. In that post, we looked at changing log levels dynamically based on error volume. In this post, we’ll look at another approach: using OS signals to change log levels at runtime without restarting the application.

The Foundation

OS Signals

Before we begin, let’s do a quick crash course on OS signals. Signals are a low-level operating system feature, and POSIX standardizes many of the common Unix signal behaviors. They notify a process that an event has happened. That event might be a user pressing Ctrl+C, a terminal closing, or the OS asking the process to terminate. Both Linux and macOS have signal handling although they may not implement the exact same set of signals. You've probably interacted with signals without realizing it. Whenever you send   CTRL+c  or when you press CTRL+z, this sends a SIGINT and SIGTSTP respectively. 
 
Some signals are left over from the early days of Unix. For example, SIGHUP was originally sent when a serial line was hung up. Today, it is often used when a terminal is closed or when a process should reload its configuration. Others are rarely called or used like SIGSYS since most applications use a library to do system calls. The good news is you can either ignore or override the default behavior of most signals with what are called signal handlers. 
   
When you define a signal handler, the kernel stores an object on what it should call when it receives that signal for that process. When the kernel gets told that it should send a signal to the process, it first looks up in the table if it has the default handler, ignore or a custom handler it then performs the appropriate action. 

Ignoring Signals

Let's look at some code samples. The first being on how to ignore:
 

  import (
    "os/signal"
    "os"
    "fmt"
    "time"
    "syscall"
    )
  
  func main() {
     signal.Ignore(syscall.SIGHUP)
     for {
       fmt.Printf("hello world")
       time.Sleep(2 * time.Second)
      }    
  }

Then when you send a signal with kill -SIGHUP <pid> it does nothing. You may want to do this to prevent corruption of data, perhaps when writing to a file so it doesn't stop the program when you're halfway between writing to a file. Pairing with the ignoring signals during critical pieces with code, you can also do signal.Reset after to restore the default (or do a custom function to set up signal handling again).

Repurposing Signals

The other option of signal handling is repurposing the signal. Many modern programs repurpose SIGHUP for config reloading, this is what Prometheus does, allowing a hot reload of the config without restarting the process. Here is how you can do this in Go:

package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    c := make(chan os.Signal, 1)
    signal.Notify(c, syscall.SIGHUP)

    go func() {
        for range c {
            reloadConfig()
        }
    }()

    for {
        fmt.Println("Hello world")
        time.Sleep(2 * time.Second)
    }
}
func reloadConfig() {
    fmt.Println("reloading config")
} 
 
This will  allow you to intercept any SIGHUP signals, so if you run   kill -SIGHUP <pid> it will print "reloading config".

Graceful Shutdowns

You can also use this to intercept usual shutdown signals like SIGTERM and SIGINT to do graceful shutdowns, like finish processing a message from a queue while no longer consuming new messages. This is usually better than ignoring the signal because most orchestrators or users will only wait so long before escalating to SIGKILL. Once that happens, your application does not get a cleanup step. SIGKILL cannot be intercepted, handled, or ignored, so the OS terminates the process immediately. SIGSTOP is similar in that it cannot be caught or ignored, but it suspends the process rather than terminating it.

Go Logging

Why use signals for log levels?

This idea is not new. Other applications expose runtime log-level controls over HTTP. Envoy, for example, allows you to send a POST request to change logging at runtime. An HTTP endpoint is a valid approach, but it brings its own challenges: you need to expose it, secure it, and make sure random users cannot increase your application's verbosity.
 
Environment variables are simple, but changing them usually requires a restart. Signals sit in the middle: they are already available on Unix-like systems, they do not require adding a web server, and they work well for operational controls like temporarily increasing log verbosity during an investigation.

Building a Logger Abstraction

Before we start handling signals, let's genericize the logging layer. If you remember from the previous post, log/slog uses integer values to represent log levels, and many logging libraries use a similar idea. We can use that same pattern for an internal interface.
package logging

import (
    "context"
    "log/slog"
)

// Level is the integer-based verbosity knob — same trick log/slog
// and zap pull. iota does the heavy lifting.
type Level int

const (
    LevelUnset Level = iota
    LevelDebug
    LevelInfo
    LevelWarn
    LevelError
)

// Backend is the pluggable sink. Any logging library can implement
// this — slog, zap, zerolog. slog.Attr is the only stdlib type we
// leak, so no third-party deps creep in.
type Backend interface {
    Log(ctx context.Context, level Level, msg string, attrs ...slog.Attr)
}

// SlogBackend is the default adapter built on top of log/slog.
type SlogBackend struct{ logger *slog.Logger }

func (b *SlogBackend) Log(ctx context.Context, level Level, msg string, attrs ...slog.Attr) {
    b.logger.LogAttrs(ctx, slogLevel(level), msg, attrs...)
}

func slogLevel(l Level) slog.Level {
    switch l {
    case LevelDebug:
        return slog.LevelDebug
    case LevelWarn:
        return slog.LevelWarn
    case LevelError:
        return slog.LevelError
    default:
        return slog.LevelInfo
    }
}

The Logger

Now that we have a way to represent the logging level, we need somewhere to actually send the log. That is what Backend is for. It keeps the logging library behind a small interface so the rest of the code does not need to care whether the logs are going to slog, zap, zerolog, or something else.

The Logger is the part the application code uses directly. It gives us the normal helper methods like Debug, Info, Warn, and Error, then delegates the actual write to the backend.

// Logger wraps any Backend and exposes the per-level helpers users
// actually want to call. The backend stays generic; the ergonomics
// live here.
type Logger struct {
    backend Backend
}

func New(b Backend) *Logger {
    return &Logger{backend: b}
}

func (l *Logger) Log(ctx context.Context, level Level, msg string, attrs ...slog.Attr) {
    l.backend.Log(ctx, level, msg, attrs...)
}

func (l *Logger) Debug(ctx context.Context, msg string, attrs ...slog.Attr) {
    l.Log(ctx, LevelDebug, msg, attrs...)
}

func (l *Logger) Info(ctx context.Context, msg string, attrs ...slog.Attr) {
    l.Log(ctx, LevelInfo, msg, attrs...)
}

func (l *Logger) Warn(ctx context.Context, msg string, attrs ...slog.Attr) {
    l.Log(ctx, LevelWarn, msg, attrs...)
}

func (l *Logger) Error(ctx context.Context, msg string, attrs ...slog.Attr) {
    l.Log(ctx, LevelError, msg, attrs...)
}


Signal-Driven Level Control

Now that we have a generic logging layer, we can make the wrapper responsible for filtering logs based on the current level. Then we can use OS signals to adjust that level at runtime. For this example, we’ll use SIGUSR1 and SIGUSR2.


//Add the level and mutex to the struct to allow dynamic logging
type Logger struct {
    backend Backend

    mu    sync.Mutex
    level Level
}


//first we need to Modify the new function to set up 
//the signal handlers at the same time
func New(b Backend, level Level) *Logger {
    l := &Logger{backend: b, level: level}
    l.listenForSignals()
    return l
}

// We need to make the wrapper the source of truth, before we were just shipping it
//to the backend.this allows the wrapper to change the level inthe case of receiving SIGUSR1 or SIGUSR2
func (l *Logger) Log(ctx context.Context, level Level, msg string, attrs ...slog.Attr) {
    l.mu.Lock()
    current := l.level
    l.mu.Unlock()
    if level < current {
        return
    }
    l.backend.Log(ctx, level, msg, attrs...)
}
// listenForSignals wires USR1 -> more verbose, USR2 → less verbose.
// Send kill -USR1 <pid> in prod to crank up Debug without a redeploy.
func (l *Logger) listenForSignals() {
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, syscall.SIGUSR1, syscall.SIGUSR2)
    go func() {
        for sig := range ch {
            l.mu.Lock()
            switch sig {
            case syscall.SIGUSR1:
                if l.level > LevelDebug {
                    l.level--
                }
            case syscall.SIGUSR2:
                if l.level < LevelError {
                    l.level++
                }
            }
            l.mu.Unlock()
        }
    }()
  }

 

So now we can change the logs on the go without any restart. For example debug logs are verbose and you may want to only log them when investigating a problem. You can even do a simple for loop to just change it to the lowest level for i in {1..4}; do kill -USR1 <pid>; done since we have a clamp this will go always to the lowest even if you send the signal 20 times. 

Conclusion

Logs are how you understand what your application is doing when you're not watching it closely. But logging everything all the time is expensive and noisy — and logging too little means you're flying blind when something goes wrong in production.

The common options each carry a cost. Environment variables are simple but require a restart. An HTTP endpoint like Envoy's works, but now you're exposing and securing another surface. Signals sit in the middle: they're already there on every Unix system, they don't require a web server, and they're simple enough to wire up in an afternoon.

I threw the code from this post into a library, mostly so the full example is easy to look at. I would treat it more as a starting point than something I would blindly drop into production.

Comments

Popular posts from this blog

Beyond Static Logging: Introducing Dynamic Log Levels in Go

My website Services stack.

How we sped up a Postgresql procedure 50X