Skip to content

Deploy a Server

patrol.Server is an http.Handler and nothing else. It opens no port, reads no config file and has no lifecycle of its own. Everything on this page is the program around it.

package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"time"
"github.com/kataras/patrol"
// The zoneinfo database, compiled into the binary, so TimeZone works in a
// container that ships no tzdata.
_ "time/tzdata"
)
func main() {
inbox, err := patrol.NewInbox(patrol.InboxOptions{
From: os.Getenv("SMTP_USER"),
FromPassword: os.Getenv("SMTP_PASSWORD"),
To: []string{"alerts@example.com"},
Host: "smtp.example.com",
Port: 587,
TimeZone: "Europe/Athens",
})
if err != nil {
log.Fatal(err)
}
const consumeTimeout = 30 * time.Second
handler := patrol.NewServer(patrol.ServerOptions{
ConsumeTimeout: consumeTimeout,
Tokens: []string{os.Getenv("PATROL_INGEST_TOKEN")},
OnError: func(err error) { log.Println("patrol:", err) },
}, inbox)
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 2 * time.Minute, // a 32 MiB batch takes a while to arrive
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
log.Println("patrol server listening on", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Println("patrol: shutdown:", err)
}
// Shutdown waits for handlers, and delivery starts after the handler
// returns. Give the last batches their ConsumeTimeout before exiting.
time.Sleep(consumeTimeout)
}

ReadTimeout has to fit the largest batch. The server accepts a body up to patrol.MaxRequestBody, 32 MiB. A 10-second read timeout will cut that off over a slow link and the sender sees a broken connection rather than a ServerError. Two minutes is comfortable; lower it along with your senders’ MaxBatchSize if you prefer.

Your proxy has its own body limit, and it is smaller. nginx defaults client_max_body_size to 1 MB and answers 413 itself with an HTML body, which a patrol.Client cannot decode into a ServerError. Raise it to match, or lower MaxBatchSize on every sender until batches fit.

TLS belongs at the edge. The library does not terminate TLS. Put the handler behind your proxy or wrap it with ListenAndServeTLS. A bearer token on a plaintext connection is a token in everyone’s logs.

The zoneinfo database has to be there. TimeZone on the Discord, Twilio and email flushers calls time.LoadLocation, which reads the system zone database. A scratch or a bare alpine image has none, and the constructor fails at startup with time zone "Europe/Athens": unknown time zone. Either install the tzdata package in the image or import _ "time/tzdata" as the program above does, which compiles the database into the binary for about 450 KB.

http.Server.Shutdown waits for handlers to return. A patrol handler returns as soon as the batch is decoded, and delivery runs after that on a detached context. So Shutdown returning tells you nothing about the last Slack post.

The pattern above waits ConsumeTimeout afterwards, which is the longest a delivery can run. If you would rather not sleep, count the deliveries in flight yourself by wrapping your flushers.

The other half of this: Server never closes its flushers. There is no Server.Close. Producer.Close is what calls Close on a flusher that implements Closer, and a server has no producer. If one of your server’s flushers is patrol.SQL, close the database yourself on the way out.

GET / is the check, and it answers the literal body OK. It is also authenticated whenever credentials are configured, so a load balancer probing it needs the credential too. Give the probe its own route on a separate mux if that is awkward:

mux.Handle("/events/", patrolServer) // the handler ignores the path
mux.HandleFunc("/healthz", ok) // your own, unauthenticated

The handler does not look at the request path, so mounting it under a prefix works. Point the senders’ BaseURL at that prefix and their POST / lands in the right place.

Tokens is a list, so a rotation is: add the new token, deploy the server, move the senders over, remove the old token, deploy again. Both are accepted in between. The same works for BasicAuth, which is a map of username to password.

OnError is the only signal the server produces. It receives one joined error per failed flush, with each part prefixed by the flusher name, so a counter keyed on that prefix tells you which destination is down. The default writes them through log/slog at error level, which is fine until you want the counter.