wardenpoint

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 21 Imported by: 0

README

wardenpoint-go

CI Go Reference

Send alerts and heartbeats to WardenPoint from long-running Go services and devices: a Raspberry Pi in a boiler room, an edge gateway on LTE, a daemon in a container. Standard library only.

Two promises shape the design:

  • The client never gets in your service's way. Fire and Resolve only put an event on a bounded in-memory queue; they never wait for the network. The queue's memory use is bounded. A dead WardenPoint, a dead network or a hung connection cost you nothing but log lines.
  • The client never loses an alert silently. Every event ends up delivered, rejected by the server (logged at ERROR with the HTTP status and the start of the response), or evicted by queue overflow (logged at WARN by Run's goroutine, so Fire itself never writes a log line). Each outcome is counted in metrics and in Status().

Install

go get github.com/WardenPoint/wardenpoint-go

Go 1.26 or newer (CI covers the two latest releases). Builds for linux/arm, linux/arm64 and anything else Go supports.

Quick start

Create a Generic webhook integration in the dashboard (Observability → Integrations) and copy its inbound URL and secret.

wp, err := wardenpoint.New(wardenpoint.Config{
	IntegrationURL:    "https://wardenpoint.com/api/v1/integrations/alerts/<uuid>",
	Secret:            os.Getenv("WARDENPOINT_SECRET"),
	HeartbeatInterval: time.Minute,
	Logger:            slog.Default(),
})
if err != nil {
	log.Fatal(err)
}
go wp.Run(ctx) // delivery and heartbeat; returns when ctx is done

wp.Fire(wardenpoint.Alert{
	ID:          "boiler-silent",
	Title:       "Boiler sensor silent",
	Description: "No reading for 3m.",
	Severity:    wardenpoint.Critical,
	Labels:      map[string]string{"service": "boiler"},
})
// ... later, when the condition clears:
wp.Resolve("boiler-silent")

A complete service

examples/boiler is a runnable service that uses every piece together. It covers signal handling and a /healthz endpoint that exposes Status(). It also shows a HeartbeatCheck that withholds the heartbeat when the sensor bus is dead, and an alert raised once and resolved once per incident. The core of it:

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	wp, err := wardenpoint.New(wardenpoint.Config{
		IntegrationURL:    os.Getenv("WARDENPOINT_URL"),
		Secret:            os.Getenv("WARDENPOINT_SECRET"),
		HeartbeatURL:      os.Getenv("WARDENPOINT_HEARTBEAT_URL"),
		HeartbeatSecret:   os.Getenv("WARDENPOINT_HEARTBEAT_SECRET"),
		HeartbeatInterval: time.Minute,
		HeartbeatCheck:    sensor.ping, // no heartbeat while the sensor is dead
		QueueSize:         50,
		Logger:            logger,
		AllowDisabled:     true, // no-op client when the env is empty (local runs)
	})
	if err != nil {
		logger.Error("wardenpoint config", "error", err)
		os.Exit(1)
	}

	done := make(chan struct{})
	go func() { defer close(done); _ = wp.Run(ctx) }()

	http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_ = json.NewEncoder(w).Encode(wp.Status())
	})
	go http.ListenAndServe("127.0.0.1:8080", nil)

	watch(ctx, wp) // Fire on the transition to "silent", Resolve on the way back
	<-done
}

API

Call What it does
New(Config) (*Client, error) Validates the config. No I/O, no goroutines.
Run(ctx) error Delivers the queue and sends heartbeats until ctx is done. Waits for its goroutines before returning. A second concurrent Run returns ErrAlreadyRunning.
Fire(Alert) error Queues a firing event. Fails only on an invalid alert (ErrInvalidAlert).
Resolve(id) error Queues a resolved event for id, reusing the title, severity and labels of the last Fire with that ID.
ResolveAlert(Alert) error Queues a resolved event with exactly these fields.
Status() Status Queue length, delivered/dropped/retry counters, last success, last error, WardenPoint's verdict on the last delivery, heartbeat state.

Resolve(id) needs the title because WardenPoint stores the title of every event on the alert. A resolve without one would rename the alert to "External alert". The client remembers title, severity, labels and source URL of the last 1024 fired IDs (descriptions are not kept). After a restart, Resolve(id) uses the ID as the title; call ResolveAlert if that matters.

Configuration
Field Default Notes
IntegrationURL, Secret required Empty → error, or a disabled no-op client with one WARN when AllowDisabled is set.
HeartbeatURL, HeartbeatSecret IntegrationURL, Secret A dedicated heartbeat integration is recommended, see below.
HeartbeatInterval 0 (off) At least 5 s. Keep it at a third of the integration's grace window (180 s by default).
HeartbeatCheck nil func(ctx) error. An error withholds the heartbeat.
QueueSize 50 Undelivered events kept in memory.
RequestTimeout 15 s One request, connect to last byte.
LateThreshold 1 min Older events get their original time appended to the description.
HTTPClient tuned for small devices Redirects are disabled even on a client you pass in.
Logger slog.Default()
Metrics none See Metrics.
Store memory See Surviving restarts.
Clock system clock wardenpointtest.Clock in tests.
HMAC false Sign requests instead of sending the secret.

Delivery

The body is the generic webhook format: external_alert_id, title, description, severity, status, labels, source_url. It also carries client_event_id, a random ID per event that WardenPoint ignores; it keeps every request body unique. Title and description are truncated to the server's limits (255 and 5000 characters) on Fire, so the server never rejects an alert for its length.

Response Outcome
2xx Delivered. The 202 body (action, notification_queued, queue_skip_reason) is logged at DEBUG and kept in Status().LastDelivery.
408, 429, 5xx, network error, timeout Retried with exponential backoff: 10 s doubling to 5 min; each delay is drawn between half and all of its step, so devices that lost the network together do not retry in lockstep. A Retry-After header (seconds or date, capped at 15 min) replaces the backoff.
3xx Rejected. Redirects are never followed: a redirect would move your secret to another URL.
Other 4xx (401, 403, 404, 413, 422, …) Rejected: logged at ERROR with the status and the start of the body, event dropped, next event proceeds.

Events are delivered strictly in order, one at a time. While the head of the queue waits to retry, nothing overtakes it.

When the queue is full, the oldest event that is not on the wire at that moment is evicted, with a WARN naming its alert_id.

An event delivered more than LateThreshold after Fire gets a line like Raised at 2026-10-18T12:00:00Z, delivered 7m12s late. appended to its description. Whoever reads it at 3 a.m. then knows the incident is older than the notification.

A 202 whose queue_skip_reason means nobody will be told (no_group, group_has_no_recipients, no_routable_contacts, …) is logged at WARN rather than DEBUG. The alert arrived, but the integration's routing sends it nowhere.

Repeated alerts are not deduplicated

Every Fire is a new event of the alert ID. WardenPoint groups events by ID, so you get one alert, not many. Each event still counts towards the integration's accumulation threshold ("page after N events in M minutes"). The client deliberately does not deduplicate. Fire when the condition starts, not on every poll while it lasts, unless repeated events are what your threshold expects.

A request whose response was lost after the server accepted it (a timeout) is retried, and the event is counted twice. Avoiding that needs an idempotency key on the server side; until then, a duplicate is the cost of never losing an alert.

Heartbeat

The heartbeat runs in its own goroutine. It does not share the alert queue, so an alert stuck in retries or rejected by the server never stops it. The first beat goes out as soon as Run starts. WardenPoint ignores an integration that has never sent anything, so until then a dead device goes unnoticed.

Each beat is a fixed alert:

{
  "external_alert_id": "wardenpoint-heartbeat",
  "title": "Heartbeat",
  "severity": "info",
  "status": "firing",
  "description": "Liveness signal from wardenpoint-go. Suppress it with a routing rule on label kind=heartbeat.",
  "labels": { "kind": "heartbeat" },
  "client_event_id": "<random>"
}

The client logs only when the heartbeat changes state: it starts or stops reaching WardenPoint, or HeartbeatCheck starts failing. An hour-long outage produces one WARN, not sixty.

Setting it up in WardenPoint
  1. Turn on heartbeat mode for the integration that receives the beats: health check mode Heartbeat, a grace window (default 180 s, at least 3× your interval), and an owner group. Without an owner group nobody is paged.
  2. Suppress the heartbeat alert itself with a routing rule. Otherwise every beat is an incoming alert that may page someone.

Use a dedicated integration per device for heartbeats (HeartbeatURL, HeartbeatSecret). The health check only looks at when the integration last received anything. On a shared integration every real alert counts as a sign of life, and on a shared heartbeat integration one live device hides a dead one. A dedicated integration also needs nothing more than a catch-all suppress rule. If you do send both to one integration, add the rule below above your other rules.

The rule through the configuration API (POST /api/v1/observability/integrations/{integration}/routing-rules, Authorization: Bearer <API key with config.write>):

{
  "name": "Swallow wardenpoint-go heartbeats",
  "label_matchers": [{ "key": "kind", "op": "=", "value": "heartbeat" }],
  "recipient_group_uuid": null,
  "threshold_count": 1,
  "window_minutes": 1,
  "below_threshold_action": "suppress",
  "threshold_action": "suppress",
  "after_threshold_action": "suppress"
}

State all five accumulation fields. Once any of them is set, the rule stops using the integration's accumulation rules: an omitted threshold and window fall back to 1, an omitted after_threshold_action to suppress, and only the two other actions inherit the integration's default action. A suppressed heartbeat still refreshes the integration's "last received" time, and that time is all the health check looks at.

How a missed heartbeat reaches people

WardenPoint checks heartbeats every two minutes, so a page comes between the grace window and about two minutes after it. When no beat arrived within the grace window, it sends one notification to the integration's owner group at priority high: Integration "…" has missed its heartbeat for over N min. When beats resume, it sends heartbeat restored, also at priority high. Routing rules play no part in this: both notifications go straight to the owner group.

HeartbeatCheck: alive is not the same as healthy
HeartbeatCheck: func(ctx context.Context) error {
	return db.PingContext(ctx)
},

When the check returns an error (or panics, or exceeds RequestTimeout), the beat is skipped and Status().Heartbeat.State becomes unhealthy. WardenPoint then pages the owner group once the grace window runs out. This is a choice: the process is up, but you are saying it is not doing its job. Leave the check nil to report liveness only.

Severity is not priority

Severity describes the alert. It does not decide how loudly people are woken. The action of the routing rule that matches the alert decides that:

Rule action Notification
escalate priority critical, starts the escalation chain (calls, follow-ups)
message priority normal, no escalation chain
suppress recorded, nobody notified

Severity is one of the conditions a rule can match on (severities: ["critical"]). So "critical alerts call the on-call engineer" is something you configure in a rule; sending Critical alone does not make it happen.

Security

  • The secret is sent only as Authorization: Bearer …, or not at all with HMAC: true. It never goes in the URL.
  • It never appears in logs, errors, Status() or metrics. That holds for network errors too: a *url.Error from net/http prints the full URL (including any ?token= you put there), so the client replaces it with a redacted message and does not wrap the original. Response bodies quoted in logs are masked as well. Config prints with secrets masked through %v, %+v, %#v and slog.
  • A panic in HeartbeatCheck is recovered. Its message is masked and reported as a failed check.
  • Plain http:// to a non-loopback host logs a WARN at New.
HMAC

With the integration's Require HMAC setting on, set HMAC: true. Each request then carries X-WardenPoint-Timestamp (Unix seconds) and X-WardenPoint-Signature: hex HMAC-SHA256 of timestamp + "." + body, keyed by the secret. The secret itself is not sent. WardenPoint refuses a signature it has already seen. The random client_event_id keeps two identical alerts fired in the same second from colliding, and a retry is signed afresh.

Timestamps must be within the integration's tolerance of the server's clock. Devices without a real-time clock need NTP before the first request. When a signed request gets 401 and the server's Date is more than a minute off, the error says the device clock is off.

Surviving restarts

By default the queue lives in memory. Run logs a WARN with the count of events that will be lost if the process exits. To keep them, implement Store:

type Store interface {
	Load(ctx context.Context) ([]Event, error)   // once, oldest first
	Add(ctx context.Context, ev Event) error
	Delete(ctx context.Context, eventID string) error
}

Event marshals to JSON as is. The client calls the store only from its delivery goroutine, never concurrently and never from Fire. A slow SD card therefore cannot block your service. As a result, an event fired a moment before a crash may not have reached the store yet. Store failures are logged and do not stop delivery. A failed Delete can only cause a duplicate after a restart, never a loss.

Metrics

type Metrics interface {
	EventDelivered()
	EventDropped(reason DropReason) // "overflow" or "rejected"
	EventRetried()
	QueueLength(n int)
	Heartbeat(ok bool)
}

A Prometheus adapter is three counters, a counter vector by reason, and two gauges. No dependency on any metrics library is imposed. Calls must be cheap and non-blocking.

Logs

slog with the fields alert_id, severity, status, http_status, attempt, queue_len, retry_in, plus error where relevant.

Level When
ERROR Event rejected by WardenPoint; store could not be loaded.
WARN Retry scheduled; queue overflow; heartbeat stopped reaching WardenPoint or was withheld; delivered but nobody will be notified; client disabled; shutdown with events in memory.
INFO Heartbeat (re)started reaching WardenPoint; events loaded from the store.
DEBUG Each delivery with WardenPoint's verdict.

Testing your code

wardenpointtest has a fake server and a manual clock:

srv := wardenpointtest.NewServer(t)
clock := wardenpointtest.NewClock(time.Now())
srv.Script("alerts", wardenpointtest.Reply{Status: 429, Header: http.Header{"Retry-After": {"30"}}})
srv.SetDefault("heartbeat", wardenpointtest.Reply{Hang: true})

wp, _ := wardenpoint.New(wardenpoint.Config{
	IntegrationURL: srv.IntegrationURL("alerts"),
	HeartbeatURL:   srv.IntegrationURL("heartbeat"),
	Secret:         "test",
	Clock:          clock,
})
go wp.Run(ctx)
wp.Fire(wardenpoint.Alert{ID: "x"})

srv.WaitRequests(ctx, "alerts", 1)
clock.WaitTimers(ctx, 1)    // the retry is parked on the clock
clock.Advance(30 * time.Second)
reqs, _ := srv.WaitRequests(ctx, "alerts", 2)

Reply supports a status, headers and a body. It can also hang until the client gives up (Hang) or until you release it (Release). Requests are recorded with headers, raw body and decoded payload. RequireAuth makes an integration check the Bearer secret or the HMAC signature (timestamp window, replayed signatures refused) the way WardenPoint does.

The library's own tests run under go test -race, check for goroutine leaks with goleak (a test-only dependency), and cover:

  • a hung server;
  • a stuck or rejected queue alongside a live heartbeat;
  • ordering, overflow and Retry-After;
  • HMAC signatures and replay;
  • secret masking on network errors and tokenised URLs.

Known limitations of the WardenPoint API

These are worked around on the client side; server-side support is planned:

  • No dedicated heartbeat endpoint: the heartbeat is an alert that a rule has to suppress, and it spends the inbound rate limit (60 requests per minute per source IP, shared by alerts and heartbeats).
  • "Heartbeat missed" and "heartbeat restored" both arrive at priority high.
  • No Idempotency-Key: a retry after a lost response counts the event twice.
  • The 202 response has no notification ID to check delivery against.

License

MIT

Documentation

Overview

Package wardenpoint sends alerts and heartbeats from long-running services and devices to a WardenPoint generic webhook integration.

The client is built for services that must keep working no matter what WardenPoint, the network or the client itself is doing:

  • Fire and Resolve never block: they validate the alert, put an event on a bounded in-memory queue and return. All network I/O happens in Run.
  • An event is never dropped silently. It is either delivered, rejected by WardenPoint (logged at ERROR with the HTTP status), or evicted by queue overflow (logged at WARN). Every drop is also counted in Metrics.
  • The heartbeat runs in its own goroutine and does not share the alert queue: an alert stuck in retries or rejected by the server never stops the heartbeat.
  • The integration secret never appears in logs, errors, Status or metrics.

Repeated alerts

Each call to Fire is a new event of the alert identified by Alert.ID. WardenPoint groups events by that ID and counts them towards the integration's accumulation threshold, so firing the same alert in a loop does not create new alerts, but it does move the threshold. The client does not deduplicate on its own: call Fire when the condition is detected, not on every check while it persists.

A request whose response is lost (a timeout after the server accepted it) is retried, and WardenPoint then counts the event twice. Until the inbound API accepts an idempotency key this is the price of never losing an alert.

Index

Constants

View Source
const (
	MaxIDLength          = 255
	MaxTitleLength       = 255
	MaxDescriptionLength = 5000
	MaxSourceURLLength   = 1024
)

Limits of the generic webhook. Fire truncates title and description to them instead of letting WardenPoint reject the whole alert with 422.

View Source
const (
	DefaultQueueSize      = 50
	DefaultRequestTimeout = 15 * time.Second
	DefaultLateThreshold  = time.Minute

	// MinHeartbeatInterval keeps a misconfigured interval from spending the
	// integration's rate limit (60 requests per minute) on heartbeats alone.
	MinHeartbeatInterval = 5 * time.Second
)

Defaults applied by New when the corresponding Config field is zero.

View Source
const (
	HeartbeatAlertID    = "wardenpoint-heartbeat"
	HeartbeatLabelKey   = "kind"
	HeartbeatLabelValue = "heartbeat"
)

The heartbeat is sent as an alert with a fixed body. WardenPoint's heartbeat health check only needs something to arrive; the label lets a routing rule suppress the alert itself (see the README).

View Source
const Version = "0.1.0"

Version is sent in the User-Agent header.

Variables

This section is empty.

Functions

This section is empty.

Types

type Alert

type Alert struct {
	ID          string            `json:"id"`
	Title       string            `json:"title,omitempty"`
	Description string            `json:"description,omitempty"`
	Severity    Severity          `json:"severity,omitempty"`
	Labels      map[string]string `json:"labels,omitempty"`
	SourceURL   string            `json:"source_url,omitempty"`
}

Alert is one condition of the service. ID identifies it across events: Fire and Resolve with the same ID refer to the same WardenPoint alert.

type AlertStatus

type AlertStatus string

AlertStatus is the state an event reports for its alert.

const (
	StatusFiring   AlertStatus = "firing"
	StatusResolved AlertStatus = "resolved"
)

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client delivers alerts and heartbeats. Create it with New, start delivery with Run, raise alerts with Fire and Resolve from any goroutine.

func New

func New(cfg Config) (*Client, error)

New validates cfg and returns a client. It starts no goroutines and makes no requests; it may log a WARN about the configuration.

With an empty IntegrationURL or Secret it returns an error, or, when cfg.AllowDisabled is set, a disabled client whose methods do nothing.

func (*Client) Fire

func (c *Client) Fire(a Alert) error

Fire queues a firing event of the alert and returns at once, without I/O. It only fails on an invalid alert. Each call is a new event of the alert a.ID; see the package documentation on repeated alerts.

func (*Client) GoString

func (c *Client) GoString() string

GoString implements fmt.GoStringer for the same reason as String.

func (*Client) LogValue

func (c *Client) LogValue() slog.Value

LogValue implements slog.LogValuer for the same reason as String.

func (*Client) Resolve

func (c *Client) Resolve(id string) error

Resolve queues a resolved event for the alert with the given ID. It resends the title, severity, labels and source URL of the last Fire with that ID: WardenPoint overwrites them with each event, and a bare resolve would rename the alert. The description is not kept. For an ID this client has not fired, e.g. after a restart, the title is the ID. Use ResolveAlert to set the fields.

func (*Client) ResolveAlert

func (c *Client) ResolveAlert(a Alert) error

ResolveAlert queues a resolved event with exactly the given fields.

func (*Client) Run

func (c *Client) Run(ctx context.Context) error

Run delivers queued events and sends heartbeats until ctx is cancelled. It returns nil after ctx is done, once every goroutine it started has exited, or ErrAlreadyRunning. Events left undelivered stay queued (and in the Store) for the next Run.

func (*Client) Status

func (c *Client) Status() Status

Status returns a snapshot of the client's state. It never blocks on I/O.

func (*Client) String

func (c *Client) String() string

String keeps the secret out of fmt output: without it %+v of a *Client would print every unexported field, the secret included.

type Clock

type Clock interface {
	Now() time.Time
	NewTimer(d time.Duration) Timer
}

Clock is the client's source of time: heartbeat ticks, retry delays, event timestamps and HMAC timestamps. HTTP timeouts always use real time.

type Config

type Config struct {
	// IntegrationURL is the full inbound URL of a generic webhook integration:
	// https://wardenpoint.com/api/v1/integrations/alerts/<integration-uuid>.
	IntegrationURL string

	// Secret is the integration secret. It is sent as a Bearer token, or used
	// only as the HMAC key when HMAC is true.
	Secret string

	// HeartbeatURL is where heartbeats go. Empty means IntegrationURL. A
	// dedicated integration is recommended, see the README.
	HeartbeatURL string

	// HeartbeatSecret is the secret of the HeartbeatURL integration. Empty
	// means Secret.
	HeartbeatSecret string

	// HeartbeatInterval is the period between heartbeats. Zero disables the
	// heartbeat. It must be shorter than the integration's heartbeat grace
	// window (180 s by default), ideally a third of it.
	HeartbeatInterval time.Duration

	// HeartbeatCheck, when set, runs before every heartbeat. If it returns an
	// error the heartbeat is not sent, so WardenPoint pages the integration's
	// owner group once the grace window expires: the service is up but not
	// healthy. Leave it nil to report liveness only. It gets RequestTimeout to
	// finish; a panic counts as a failed check.
	HeartbeatCheck func(ctx context.Context) error

	// QueueSize bounds the number of undelivered events. On overflow the
	// oldest event that is not being sent right now is evicted.
	QueueSize int

	// RequestTimeout bounds one HTTP request, from connect to the end of the
	// response body.
	RequestTimeout time.Duration

	// LateThreshold: an event delivered later than this after it was created
	// gets the original time appended to its description.
	LateThreshold time.Duration

	// HTTPClient is used for all requests. Nil means a client tuned for small
	// devices. Redirects are never followed, even with a custom client: its
	// CheckRedirect is replaced on a copy.
	HTTPClient *http.Client

	// Logger receives the client's logs. Nil means slog.Default().
	Logger *slog.Logger

	// Metrics receives counters and gauges. Nil disables metrics.
	Metrics Metrics

	// Store persists the queue across restarts. Nil keeps it in memory only.
	Store Store

	// Clock is the time source for timers and timestamps. Nil means the
	// system clock; tests pass wardenpointtest.Clock.
	Clock Clock

	// HMAC signs every request with the integration's HMAC scheme instead of
	// sending the secret. It must match the integration's "Require HMAC"
	// setting and applies to the heartbeat integration as well. Signatures
	// carry a timestamp, so the device clock must be synchronised (NTP).
	HMAC bool

	// AllowDisabled makes New return a working no-op client instead of an
	// error when IntegrationURL or Secret is empty, e.g. in a development
	// environment without credentials. The client logs one WARN.
	AllowDisabled bool
}

Config configures a Client. Only IntegrationURL and Secret are required.

Config implements fmt.Stringer, fmt.GoStringer and slog.LogValuer with the secrets masked, so logging or printing it does not leak them.

func (Config) GoString

func (c Config) GoString() string

GoString implements fmt.GoStringer, so %#v does not print the secret.

func (Config) LogValue

func (c Config) LogValue() slog.Value

LogValue implements slog.LogValuer with secrets masked.

func (Config) String

func (c Config) String() string

String implements fmt.Stringer with secrets masked.

type ConfigError

type ConfigError string

ConfigError is returned by New for an unusable Config. Its message never contains a URL or a secret.

func (ConfigError) Error

func (e ConfigError) Error() string

type Delivery

type Delivery struct {
	AlertID    string      `json:"-"`
	Status     AlertStatus `json:"-"`
	HTTPStatus int         `json:"-"`
	At         time.Time   `json:"-"`
	AlertUUID  string      `json:"alert_uuid"`
	EventUUID  string      `json:"event_uuid"`
	// Action is the accumulation action taken: suppress, message or
	// escalate.
	Action             string `json:"action"`
	NotificationQueued bool   `json:"notification_queued"`
	// QueueSkipReason says why nobody was notified, e.g. suppressed_by_rule
	// or no_group; empty when a notification was queued.
	QueueSkipReason string `json:"queue_skip_reason"`
	EventCount      int    `json:"event_count"`
	ThresholdCount  int    `json:"threshold_count"`
}

Delivery is WardenPoint's answer to an accepted event.

type DropReason

type DropReason string

DropReason says why an event left the queue without being delivered.

const (
	// DropOverflow: the queue was full and the event was the oldest one not
	// being sent at that moment.
	DropOverflow DropReason = "overflow"
	// DropRejected: WardenPoint answered with a status that a retry cannot
	// fix (4xx other than 408 and 429, or a redirect).
	DropRejected DropReason = "rejected"
)

type Event

type Event struct {
	// ID is unique per event and is sent as client_event_id. It also makes
	// every request body unique, which HMAC replay protection relies on.
	ID        string      `json:"id"`
	Alert     Alert       `json:"alert"`
	Status    AlertStatus `json:"status"`
	CreatedAt time.Time   `json:"created_at"`
	// Attempts counts failed delivery attempts. It is not persisted on each
	// attempt, so it restarts after a reload from the Store.
	Attempts int `json:"attempts,omitempty"`
}

Event is one queued delivery of an alert. It is exported for Store implementations; it marshals to JSON as is.

type HeartbeatState

type HeartbeatState string

HeartbeatState is the heartbeat's last known outcome.

const (
	HeartbeatDisabled HeartbeatState = "disabled"
	// HeartbeatUnknown: Run has not sent the first heartbeat yet.
	HeartbeatUnknown HeartbeatState = "unknown"
	HeartbeatOK      HeartbeatState = "ok"
	// HeartbeatFailing: the last heartbeat did not reach WardenPoint.
	HeartbeatFailing HeartbeatState = "failing"
	// HeartbeatUnhealthy: HeartbeatCheck failed, the heartbeat was withheld.
	HeartbeatUnhealthy HeartbeatState = "unhealthy"
)

type HeartbeatStatus

type HeartbeatStatus struct {
	State       HeartbeatState
	LastAttempt time.Time
	LastOK      time.Time
	LastError   string
}

HeartbeatStatus describes the heartbeat.

type Metrics

type Metrics interface {
	// EventDelivered counts events accepted by WardenPoint (2xx).
	EventDelivered()
	// EventDropped counts events that will never be delivered.
	EventDropped(reason DropReason)
	// EventRetried counts failed attempts that will be retried.
	EventRetried()
	// QueueLength reports the number of undelivered events after each change.
	QueueLength(n int)
	// Heartbeat reports the outcome of each heartbeat tick. ok is false when
	// the request failed or HeartbeatCheck stopped it.
	Heartbeat(ok bool)
}

Metrics receives the client's counters and gauges. Implementations must be safe for concurrent use and must not block: methods are called from Fire and from the delivery path, sometimes while an internal lock is held.

A Prometheus adapter is a few lines: counters for the first three methods, gauges for the last two.

type SentinelError

type SentinelError string

SentinelError is a constant error value; the sentinels below are constants rather than package variables, so nothing can reassign them.

const (
	// ErrAlreadyRunning is returned by Run when another Run of the same
	// client has not returned yet.
	ErrAlreadyRunning SentinelError = "wardenpoint: Run is already running"

	// ErrInvalidAlert wraps every validation error from Fire and Resolve.
	ErrInvalidAlert SentinelError = "wardenpoint: invalid alert"
)

func (SentinelError) Error

func (e SentinelError) Error() string

type Severity

type Severity string

Severity describes the alert. It is not the notification priority: how loudly people are woken is decided by the action of the WardenPoint routing rule that matches the alert. Severity is one of the things a rule can match on.

const (
	Critical Severity = "critical"
	High     Severity = "high"
	Medium   Severity = "medium"
	Low      Severity = "low"
	Info     Severity = "info"
)

type Status

type Status struct {
	Enabled     bool
	Running     bool
	QueueLength int
	Delivered   uint64
	Dropped     uint64
	Retries     uint64
	LastAttempt time.Time
	LastSuccess time.Time
	// LastError describes the last failed alert delivery, secrets masked.
	LastError   string
	LastErrorAt time.Time
	// LastDelivery is WardenPoint's answer to the last delivered event.
	LastDelivery *Delivery
	Heartbeat    HeartbeatStatus
}

Status is a snapshot of the client's state.

type Store

type Store interface {
	// Load returns the stored events, oldest first. It is called once, at
	// the start of the first Run.
	Load(ctx context.Context) ([]Event, error)
	// Add stores an event. Events are added in queue order.
	Add(ctx context.Context, ev Event) error
	// Delete removes the event with the given Event.ID. Deleting an unknown
	// ID is not an error.
	Delete(ctx context.Context, eventID string) error
}

Store persists queued events, so alerts raised before a crash or a reboot are delivered after it.

The client keeps the queue in memory and mirrors it into the Store from the delivery goroutine only: Fire never waits for the Store, and methods are never called concurrently. An event raised less than a moment before the process dies may therefore exist only in memory.

A failing Store does not stop delivery: the client logs a WARN and keeps the events in memory. A failed Delete means the event can be sent again after a restart, never that it is lost.

type Timer

type Timer interface {
	C() <-chan time.Time
	Stop() bool
}

Timer is the subset of *time.Timer the client uses.

Directories

Path Synopsis
examples
boiler command
Command boiler is a complete example service: it watches a sensor, raises and resolves an alert in WardenPoint, sends a heartbeat that stops when the sensor bus is unreadable, and exposes the client status on /healthz.
Command boiler is a complete example service: it watches a sensor, raises and resolves an alert in WardenPoint, sends a heartbeat that stops when the sensor bus is unreadable, and exposes the client status on /healthz.
Package wardenpointtest provides fakes for testing code that uses the wardenpoint client: an HTTP server that imitates the generic webhook endpoint and a manual clock.
Package wardenpointtest provides fakes for testing code that uses the wardenpoint client: an HTTP server that imitates the generic webhook endpoint and a manual clock.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL