Go integration

Wire Drumbeats into a Go service using only net/http, with panics and goroutines handled properly.

go
package main

import (
    "net/http"
    "time"
)

const monitor = "https://api.drumbeats.io/v1/ping/<monitor-id>"

var client = &http.Client{Timeout: 3 * time.Second}

func main() {
    ping("/start")
    if err := runJob(); err != nil {
        ping("/failure")
        return
    }
    ping("/success")
}

func ping(event string) {
    resp, err := client.Get(monitor + event)
    if err != nil {
        return // side channel, never fail the job
    }
    resp.Body.Close()
}
package main

import (
    "net/http"
    "time"
)

const monitor = "https://api.drumbeats.io/v1/ping/<monitor-id>"

var client = &http.Client{Timeout: 3 * time.Second}

func main() {
    ping("/start")
    if err := runJob(); err != nil {
        ping("/failure")
        return
    }
    ping("/success")
}

func ping(event string) {
    resp, err := client.Get(monitor + event)
    if err != nil {
        return // side channel, never fail the job
    }
    resp.Body.Close()
}

Always close the response body, even on a ping you do not read. Leaking bodies in a long-lived worker exhausts the connection pool, and the symptom shows up somewhere unrelated hours later.

No third-party dependencies. net/http covers everything here.

Wrap it once#

internal/monitoring/drumbeats.go
package monitoring

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "os"
    "time"
)

const (
    pingTimeout = 3 * time.Second
    maxPayload  = 20_000
)

var baseURL = func() string {
    if v := os.Getenv("DRUMBEATS_BASE_URL"); v != "" {
        return v
    }
    return "https://api.drumbeats.io/v1"
}()

var Monitors = map[string]string{
    "daily_backup":    "11111111-2222-3333-4444-555555555555",
    "hourly_sync":     "66666666-7777-8888-9999-aaaaaaaaaaaa",
    "newsletter_send": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
}

var client = &http.Client{Timeout: pingTimeout}

// ping is best effort. It never returns an error and never blocks
// longer than pingTimeout.
func ping(monitorID, event, runID, payload string) {
    u, err := url.Parse(fmt.Sprintf("%s/ping/%s/%s", baseURL, monitorID, event))
    if err != nil {
        return
    }
    if runID != "" {
        q := u.Query()
        q.Set("run_id", runID)
        u.RawQuery = q.Encode()
    }

    var resp *http.Response
    if payload == "" {
        resp, err = client.Get(u.String())
    } else {
        if len(payload) > maxPayload {
            payload = payload[len(payload)-maxPayload:]
        }
        body, _ := json.Marshal(map[string]string{"payload": payload})
        resp, err = client.Post(u.String(), "application/json", bytes.NewReader(body))
    }
    if err != nil {
        return
    }
    resp.Body.Close()
}

// WithMonitor runs fn under the given monitor key. The job's error is
// propagated; ping failures are swallowed. A panic in fn is reported as a
// failure and then re-panics.
func WithMonitor(key, runID string, fn func() error) (err error) {
    id, ok := Monitors[key]
    if !ok {
        return fmt.Errorf("unknown monitor key %q", key)
    }
    if runID == "" {
        runID = fmt.Sprintf("%s-%d", key, time.Now().UnixNano())
    }

    ping(id, "start", runID, "")

    defer func() {
        if r := recover(); r != nil {
            ping(id, "failure", runID, fmt.Sprintf("panic: %v", r))
            panic(r)
        }
    }()

    if err = fn(); err != nil {
        ping(id, "failure", runID, err.Error())
        return err
    }
    ping(id, "success", runID, "")
    return nil
}
package monitoring

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "os"
    "time"
)

const (
    pingTimeout = 3 * time.Second
    maxPayload  = 20_000
)

var baseURL = func() string {
    if v := os.Getenv("DRUMBEATS_BASE_URL"); v != "" {
        return v
    }
    return "https://api.drumbeats.io/v1"
}()

var Monitors = map[string]string{
    "daily_backup":    "11111111-2222-3333-4444-555555555555",
    "hourly_sync":     "66666666-7777-8888-9999-aaaaaaaaaaaa",
    "newsletter_send": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
}

var client = &http.Client{Timeout: pingTimeout}

// ping is best effort. It never returns an error and never blocks
// longer than pingTimeout.
func ping(monitorID, event, runID, payload string) {
    u, err := url.Parse(fmt.Sprintf("%s/ping/%s/%s", baseURL, monitorID, event))
    if err != nil {
        return
    }
    if runID != "" {
        q := u.Query()
        q.Set("run_id", runID)
        u.RawQuery = q.Encode()
    }

    var resp *http.Response
    if payload == "" {
        resp, err = client.Get(u.String())
    } else {
        if len(payload) > maxPayload {
            payload = payload[len(payload)-maxPayload:]
        }
        body, _ := json.Marshal(map[string]string{"payload": payload})
        resp, err = client.Post(u.String(), "application/json", bytes.NewReader(body))
    }
    if err != nil {
        return
    }
    resp.Body.Close()
}

// WithMonitor runs fn under the given monitor key. The job's error is
// propagated; ping failures are swallowed. A panic in fn is reported as a
// failure and then re-panics.
func WithMonitor(key, runID string, fn func() error) (err error) {
    id, ok := Monitors[key]
    if !ok {
        return fmt.Errorf("unknown monitor key %q", key)
    }
    if runID == "" {
        runID = fmt.Sprintf("%s-%d", key, time.Now().UnixNano())
    }

    ping(id, "start", runID, "")

    defer func() {
        if r := recover(); r != nil {
            ping(id, "failure", runID, fmt.Sprintf("panic: %v", r))
            panic(r)
        }
    }()

    if err = fn(); err != nil {
        ping(id, "failure", runID, err.Error())
        return err
    }
    ping(id, "success", runID, "")
    return nil
}
go
err := monitoring.WithMonitor("daily_backup", "", func() error {
    if err := dumpDatabase(); err != nil {
        return fmt.Errorf("dump: %w", err)
    }
    return uploadToS3()
})
err := monitoring.WithMonitor("daily_backup", "", func() error {
    if err := dumpDatabase(); err != nil {
        return fmt.Errorf("dump: %w", err)
    }
    return uploadToS3()
})

The defer with recover reports a panic as a failure and then re-panics, so Drumbeats hears about it without changing your program's crash behaviour.

What happens when it breaks#

SituationWhat Drumbeats seesWhat to do
fn returns an errorfailure with the error textNothing. The wrapper handles it
fn panicsfailure with the panic value, then the panic continuesNothing. The recover block handles it
A goroutine started inside fn panicsNothing. A panic in another goroutine kills the process outrightRecover inside each goroutine, or do not spawn detached work inside a wrapped job
fn returns before its goroutines finishsuccess fires while work is still runningUse errgroup.Wait() or a sync.WaitGroup before returning
Context cancelled and the error swallowedsuccess on a run that did not completeReturn ctx.Err() rather than treating cancellation as normal
The process is SIGKILLedA start with no finishSet max_duration_seconds so the hang is caught server-side

The goroutine rows matter most in Go. A WithMonitor that returns while its workers are still going will report success for work that has not happened yet.

How you get alerted#

A failure opens a FAILED incident once failure_tolerance is reached and pages every notification group on the monitor. The error string or panic value rides along as the payload.

Worker pools#

Call WithMonitor per message, using the message ID as the run ID:

go
for msg := range messages {
    go func(m Message) {
        _ = monitoring.WithMonitor("order_processor", m.ID, func() error {
            return process(m)
        })
    }(msg)
}
for msg := range messages {
    go func(m Message) {
        _ = monitoring.WithMonitor("order_processor", m.ID, func() error {
            return process(m)
        })
    }(msg)
}

A hundred concurrent messages produce a hundred distinct runs on one monitor, kept apart by run_id. Do not create a monitor per worker.

Next#

Production hardening for the language-agnostic patterns. Event-driven pings for correlation details. Monitor types for picking the right type. Alternatives if you are still choosing a vendor.