Skip to content

Run your own Server

A patrol Server is the other half of the HTTP client. It accepts batches over HTTP and hands them to flushers it owns. One place holds the Slack token, the SMTP password and the Twilio account; every service that reports events holds a URL and one credential.

NewServer(opts, flushers...) returns a *Server and no error. The value is an http.Handler: mount it wherever your router puts it.

// source: README.md#L119-L127
inbox, err := patrol.NewInbox(inboxOpts)
if err != nil {
log.Fatal(err)
}
srv := patrol.NewServer(patrol.ServerOptions{
BasicAuth: map[string]string{"user": "pass"},
OnError: func(err error) { log.Println(err) },
}, inbox)
log.Fatal(http.ListenAndServe(":8080", srv))
// source: http_server.go#L35-L48
// ServerOptions contains configuration options for the HTTP Server.
type ServerOptions struct {
// ConsumeTimeout bounds the delivery of one received batch to the flushers. Zero means no limit.
ConsumeTimeout time.Duration `json:"consume_timeout" yaml:"ConsumeTimeout" toml:"ConsumeTimeout"`
// BasicAuth, when not empty, is the username to password map a request may satisfy.
BasicAuth map[string]string `json:"basic_auth" yaml:"BasicAuth" toml:"BasicAuth"`
// Tokens, when not empty, lists the bearer tokens a request may present as
// "Authorization: Bearer <token>" instead of a username and password. A
// Client sends one through ClientOptions.Token. When both BasicAuth and
// Tokens are set, either credential is accepted.
Tokens []string `json:"tokens" yaml:"Tokens" toml:"Tokens"`
// OnError receives delivery errors. Defaults to logging through log/slog.
OnError func(err error) `json:"-" yaml:"-" toml:"-"`
}
Field JSON YAML and TOML Default
ConsumeTimeout consume_timeout ConsumeTimeout 0, no limit
BasicAuth basic_auth BasicAuth empty, no password check
Tokens tokens Tokens empty, no token check
OnError - - log/slog at error level

With neither BasicAuth nor Tokens, every request is accepted. That is the right default for a handler behind a private network or a gateway that already authenticates, and the wrong one for anything reachable from outside. Set at least one before the port is public.

With both set, either credential opens the door: a request with a listed bearer token passes, and so does a request with a valid username and password. Tokens entries are compared in constant time, against every entry, without stopping at the first match, so response time reveals nothing about which token was close. The BasicAuth map goes through kataras/basicauth, which compares an unknown username against a decoy for the same reason.

ConsumeTimeout bounds delivery of one received batch. Zero means a stuck flusher stays stuck; 30 seconds is a reasonable starting number for a server with an SMTP flusher on it.

Request Answer
GET / 200 with the literal body OK, after the credential check
POST / 200 with an empty body as soon as the batch is decoded. Delivery happens after the response
Anything else 405, Allow: GET, POST, METHOD_NOT_ALLOWED
No or wrong credential 401, WWW-Authenticate, UNAUTHENTICATED
Body over 32 MiB 413, PAYLOAD_TOO_LARGE
Body that is not a JSON array of events 400, INVALID_ARGUMENT

The WWW-Authenticate value is Basic realm="patrol" when a BasicAuth map is configured, so a browser can prompt, and Bearer realm="patrol" when only tokens are.

The method check runs before the credential check, so a PUT with no credential is answered 405 rather than 401. The path is not checked at all: the handler answers on whatever path it is mounted under, and a Client always calls / relative to its BaseURL.

An error body is this object, with Content-Type: application/json; charset=utf-8:

{ "http_error_code": "PAYLOAD_TOO_LARGE", "message": "http: request body too large" }

A patrol.Client decodes it into a patrol.ServerError, so the sender switches on ErrorCode rather than on a status code.

// source: http_server.go#L26-L33
const (
ErrorCodeMethodNotAllowed = "METHOD_NOT_ALLOWED"
ErrorCodeUnauthenticated = "UNAUTHENTICATED"
ErrorCodeInvalidArgument = "INVALID_ARGUMENT"
ErrorCodePayloadTooLarge = "PAYLOAD_TOO_LARGE"
ErrorCodeResourceExhausted = "RESOURCE_EXHAUSTED"
ErrorCodeUnavailable = "UNAVAILABLE"
)
Constant Value Status Written by
ErrorCodeMethodNotAllowed METHOD_NOT_ALLOWED 405 Server
ErrorCodeUnauthenticated UNAUTHENTICATED 401 Server
ErrorCodeInvalidArgument INVALID_ARGUMENT 400 Server
ErrorCodePayloadTooLarge PAYLOAD_TOO_LARGE 413 Server
ErrorCodeResourceExhausted RESOURCE_EXHAUSTED 429 your server, or Patrol Cloud
ErrorCodeUnavailable UNAVAILABLE 503 your server, or Patrol Cloud

The last two are not written by Server. They are there so a metered or a degraded server speaks the same vocabulary, and a Client written against these constants handles all six. Patrol Cloud answers RESOURCE_EXHAUSTED when an account is past its quota grace period.

The response goes out as soon as the batch is decoded. Then, on a context detached from the request with context.WithoutCancel, the server stamps ReceivedAt on every event, sorts the batch by capture time, and hands it to every flusher through patrol.FlushAll. Delivery outlives the request on purpose: a client that hangs up mid-flush must not cancel a Slack post that is already in the air.

Two things the server does not do, both of which belong to the producer that sent the batch: it does not merge repeats (MergeWindow is a producer option) and it does not re-batch. What arrives in one POST is delivered as one batch.

A batch that decodes to zero events is answered 200 and nothing is delivered.

Server is a small handler over four exported pieces, and those pieces are exported so a program with its own routing, metering or storage can speak the same protocol without copying the internals: patrol.DecodeEvents, patrol.WriteServerError, the ErrorCode* constants and patrol.FlushAll. That is what Custom flushers covers, and it is how Patrol Cloud ingests from an unmodified patrol.Client.

Next: deploying one.