Help us shape the future of Go! Take the 2026 Go Developer Survey

endpoint

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 29, 2026 License: BSD-3-Clause Imports: 12 Imported by: 0

README

endpoint — go-net-health

Docs License Go Coverage

The served side of health checks — one http.Handler answering /healthz, /readyz and /metrics, the last in the Prometheus text exposition format 0.0.4, with zero dependencies beyond the standard library.

go-net-health/health is the client side: it probes an endpoint. This package is the endpoint.

There is no prometheus/client_golang here, no registry and no metric objects. A Collector is a function called on every scrape; it writes the current value of what it knows to a Writer. A binary that already keeps its own counters exposes them without importing a metrics framework.

Install

go get github.com/go-net-health/endpoint

Usage

package main

import (
	"errors"
	"net/http"
	"sync/atomic"
	"time"

	"github.com/go-net-health/endpoint"
)

var (
	started     = time.Now()
	served      atomic.Int64
	draining    atomic.Bool
	errDraining = errors.New("draining")
)

func main() {
	ops := endpoint.Handler(endpoint.Options{
		Ready: func() error {
			if draining.Load() {
				return errDraining // /readyz answers 503 "draining\n"
			}
			return nil
		},
		Collectors: []endpoint.Collector{
			endpoint.GoRuntime,
			endpoint.BuildInfo("myapp"),
			func(w *endpoint.Writer) {
				w.Gauge("process_start_time_seconds", "Start time of the process since the Unix epoch.",
					endpoint.S(float64(started.Unix())))
				w.Counter("myapp_requests_total", "Requests served.",
					endpoint.S(float64(served.Load())))
			},
		},
	})
	go http.ListenAndServe("127.0.0.1:9090", ops)
	// ... the application's own server ...
}

Mounted under a prefix: mux.Handle("/-/", http.StripPrefix("/-", ops)).

Behaviour

Path GET / HEAD Other methods
/healthz 200 ok — always: liveness means the process can answer 405, Allow: GET, HEAD
/readyz 200 ok if Ready is nil or returns nil, else 503 + the error text 405
/metrics 200, Content-Type: text/plain; version=0.0.4; charset=utf-8 405
anything else 404 404

Every response carries Cache-Control: no-store. A Collector (or Ready) that panics does not kill the server or drop the connection: the panic is recovered and that request answers 500, naming the collector and the panic value. A scrape is all or nothing — half a scrape would make the failed collector's series look absent rather than unknown.

What the Writer guarantees

Whatever a collector writes, the scrape stays parseable:

  • Names are checked against the Prometheus data model: metric names [a-zA-Z_:][a-zA-Z0-9_:]*, label names [a-zA-Z_][a-zA-Z0-9_]* and not starting with __ (reserved). Label values must be valid UTF-8.
  • Each Counter / Gauge call is atomic. An invalid name, label, value or a duplicated label set drops the whole call — never part of it — and is reported twice: Writer.Err() returns the first problem, and the scrape ends with one # endpoint: skipped … comment line per problem (Prometheus ignores comments; a person reading the scrape sees them).
  • The same family written twice (two collectors) is merged under one # HELP / # TYPE when the types agree; the first non-empty help wins. A different type is a problem as above and the first type stands.
  • Duplicate series are refused, within a call and across merged calls. This matters because Prometheus' own parsers do not refuse them: the server parser accepts x 1\nx 2, and even a second # TYPE line, and the duplicate sample is then dropped at ingestion.
  • Escaping follows 0.0.4 exactly: label values escape \, " and line feed; HELP escapes \ and line feed. Invalid UTF-8 in HELP is replaced by U+FFFD.
  • Numbers: +Inf, -Inf, NaN; an integer of magnitude ≤ 2^53 in plain decimal (1234567, not 1.234567e+06 — byte counts stay greppable); anything else in Go's shortest round-tripping form ('g', -1). Both forms are what strconv.ParseFloat, and so every Prometheus parser, reads back to the identical float64.
  • Nothing is renamed. Counter names conventionally end in _total; that is the author's choice to make, since a rename would break every query written against the name they chose. Values are not checked either.
Built-in collectors
  • GoRuntime — go_goroutines, go_memstats_heap_alloc_bytes, go_memstats_sys_bytes (gauges) and go_gc_cycles_total (counter), under the names client_golang uses so existing dashboards keep working. Read from runtime/metrics, not runtime.ReadMemStats, which stops the world on every call — once per scrape, per scraper, for the life of the process. process_start_time_seconds is left to the program, which alone knows when it considers itself started.
  • BuildInfo(prefix) — <prefix>_build_info{version,goversion,revision} 1 from debug.ReadBuildInfo, read once.

Tests & validation

COVERPKG=$(go list ./... | paste -sd, -)
go test -race -coverpkg="$COVERPKG" -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1   # 100.0%

cd internal/promcheck && go test -race ./...

The library's own tests compare exact golden output and run every scrape through a strict line-level checker (strictCheck in writer_test.go) that also refuses a second # TYPE and interleaved families — each with a positive control proving the checker can fail.

internal/promcheck is a separate, test-only module that parses real scrapes with Prometheus' own parsers: the server's (prometheus/prometheus model/textparse, chosen from the response's Content-Type with no fallback, exactly as a scrape does) and the client library's (prometheus/common expfmt, legacy 0.0.4 name validation). Every tricky label value, help text and number — +Inf, NaN, -0, 2^53, MaxFloat64 — must read back bit-identical. Being its own module, its dependencies never reach this module's go.mod; CI runs it in its own job, and checks the library's dependency list is empty.

CI runs on Linux, macOS and Windows with the race detector and a 100% coverage gate, plus the six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x).

License

BSD-3-Clause — see LICENSE. Copyright the go-net-health/endpoint authors.

Documentation

Overview

Package endpoint is the served side of health checking: an http.Handler answering /healthz, /readyz and /metrics, the last in the Prometheus text exposition format 0.0.4.

It depends on the standard library only — no prometheus/client_golang — so a size-conscious binary can expose metrics without importing a metrics framework. There are no registries and no metric objects: a Collector is a function called on every scrape, which writes the current values of its families to a Writer.

http.Handle("/", endpoint.Handler(endpoint.Options{
	Ready:      func() error { return db.Ping() },
	Collectors: []endpoint.Collector{endpoint.GoRuntime, endpoint.BuildInfo("myapp")},
}))

What a Writer guarantees

Whatever a Collector writes, a scrape stays parseable. Names are checked against the Prometheus data model — metric names must match [a-zA-Z_:][a-zA-Z0-9_:]*, label names [a-zA-Z_][a-zA-Z0-9_]* and must not start with "__" (reserved for Prometheus itself). A call to Counter or Gauge is atomic: if its name, any label name, any label value (which must be valid UTF-8), or any label set (duplicated within a sample, or already written for that family) is wrong, none of its samples are written. The call is recorded instead: Writer.Err reports the first such problem, and the scrape ends with one "# endpoint: skipped ..." comment line per problem, which Prometheus ignores and a person reading the scrape sees.

Writing a family name twice in one scrape — typically from two collectors — merges the samples under a single # HELP and # TYPE if both calls use the same type; the first non-empty help text wins. A second call with the other type is a problem as above, and the first type stands.

Values are not checked: a Counter may be given a negative or decreasing value, and Prometheus will treat it as a reset. Names are not rewritten either: the convention that counter names end in "_total" is the caller's to follow — renaming a metric behind its author's back would break every query written against the name they chose.

Number formatting

+Inf, -Inf and NaN are written as such. A value that is an integer of magnitude at most 2^53 is written in plain decimal (1234567, not 1.234567e+06); every other value in the shortest form that parses back to the same float64 (strconv 'g', -1). Both forms are accepted by Go's strconv.ParseFloat, which is what the Prometheus parsers use; the plain form is chosen because byte and object counts are integers and are easier to read and grep in full, and 2^53 is where float64 stops representing every integer exactly.

Index

Examples

Constants

View Source
const MetricsContentType = "text/plain; version=0.0.4; charset=utf-8"

MetricsContentType is the Content-Type of a /metrics response.

Variables

This section is empty.

Functions

func GoRuntime

func GoRuntime(w *Writer)

GoRuntime is a Collector for go_goroutines, go_memstats_heap_alloc_bytes, go_memstats_sys_bytes and go_gc_cycles_total.

The values come from runtime/metrics rather than runtime.ReadMemStats. ReadMemStats stops the world on every call — every goroutine of the process pauses so the runtime can take a consistent snapshot — and a scrape runs every few seconds from every scraper, for the lifetime of the process. runtime/metrics.Read reads these values without stopping the world, and is the interface the Go team maintains going forward.

process_start_time_seconds is deliberately not here: only the program knows when it considers itself started. Write it from your own Collector.

func Handler

func Handler(o Options) http.Handler

Handler serves GET and HEAD on three paths:

  • /healthz always answers 200 "ok\n": liveness, the process can answer.
  • /readyz answers 200 "ok\n" when Options.Ready returns nil (or is nil), and 503 with the error text otherwise.
  • /metrics runs every collector into a fresh Writer and answers 200 with Content-Type MetricsContentType.

Any other method on those paths is 405 with an Allow header; any other path is 404. Paths are matched exactly: mount the handler under a prefix with http.StripPrefix.

A Collector, or Ready, that panics does not take the process down or drop the connection: the panic is recovered and that one request answers 500 with the panic value in the body. A scrape is all or nothing — a partial /metrics would make every series of the failed collector look absent rather than unknown.

Every response carries Cache-Control: no-store, so no proxy answers a probe with a stale verdict.

Example
package main

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sync/atomic"

	"github.com/go-net-health/endpoint"
)

func main() {
	var draining atomic.Bool
	var served atomic.Int64

	h := endpoint.Handler(endpoint.Options{
		Ready: func() error {
			if draining.Load() {
				return errors.New("draining")
			}
			return nil
		},
		Collectors: []endpoint.Collector{
			// endpoint.GoRuntime and endpoint.BuildInfo("myapp") go here too.
			func(w *endpoint.Writer) {
				w.Counter("myapp_requests_total", "Requests served.", endpoint.S(float64(served.Load())))
			},
		},
	})
	srv := httptest.NewServer(h) // in a program: http.ListenAndServe(":9090", h)
	defer srv.Close()

	get := func(path string) {
		res, err := http.Get(srv.URL + path)
		if err != nil {
			panic(err)
		}
		defer res.Body.Close()
		b, _ := io.ReadAll(res.Body)
		fmt.Printf("%s %d %s", path, res.StatusCode, b)
	}
	served.Add(42)
	get("/healthz")
	get("/readyz")
	draining.Store(true)
	get("/readyz")
	get("/metrics")
}
Output:
/healthz 200 ok
/readyz 200 ok
/readyz 503 draining
/metrics 200 # HELP myapp_requests_total Requests served.
# TYPE myapp_requests_total counter
myapp_requests_total 42

Types

type Collector

type Collector func(*Writer)

A Collector adds its families to a scrape. It is called on every scrape, from the request goroutine; two scrapes running at once call it concurrently, so it must be safe for concurrent use.

func BuildInfo

func BuildInfo(prefix string) Collector

BuildInfo returns a Collector for <prefix>_build_info{version,goversion,revision} 1 from debug.ReadBuildInfo.

version is the main module's version ("(devel)" for a build that is not from a tagged module), goversion the Go release that built the binary, and revision the vcs.revision stamped by the go command — empty when the build was not made from a VCS checkout, which Prometheus reads as the label being absent. The build information is read once, when BuildInfo is called. An invalid prefix makes every scrape skip the family, as for any invalid name.

type Label

type Label struct{ Name, Value string }

A Label is one name="value" pair of a sample.

func L

func L(name, value string) Label

L returns the Label name="value".

type Options

type Options struct {
	// Ready reports whether the process should receive traffic; nil means always ready.
	// /readyz answers 200 "ok\n" or 503 with the error text.
	Ready func() error
	// Collectors are called in order on every scrape of /metrics. Nil
	// entries are ignored.
	Collectors []Collector
}

Options configures a Handler.

type Sample

type Sample struct {
	Labels []Label
	Value  float64
}

A Sample is one value of a family, identified by its labels.

func S

func S(v float64, labels ...Label) Sample

S returns a Sample of value v with the given labels.

type Writer

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

A Writer writes one scrape in text format 0.0.4.

The zero value is ready to use. Families are buffered and written, in the order their names were first seen, by WriteTo. A Writer is not safe for concurrent use; the Handler gives every scrape its own.

Example
package main

import (
	"fmt"
	"os"

	"github.com/go-net-health/endpoint"
)

func main() {
	var w endpoint.Writer
	w.Gauge("queue_length", "Jobs waiting, by queue.",
		endpoint.S(3, endpoint.L("queue", "thumbnails")),
		endpoint.S(0, endpoint.L("queue", `say "hi"`)))
	w.Gauge("queue_length", "", endpoint.S(1, endpoint.L("queue", "mail"))) // merged
	w.Counter("queue_length", "", endpoint.S(1))                            // wrong type: skipped
	w.WriteTo(os.Stdout)
	fmt.Println(w.Err())
}
Output:
# HELP queue_length Jobs waiting, by queue.
# TYPE queue_length gauge
queue_length{queue="thumbnails"} 3
queue_length{queue="say \"hi\""} 0
queue_length{queue="mail"} 1
# endpoint: skipped counter "queue_length": already written as a gauge
endpoint: skipped counter "queue_length": already written as a gauge

func (*Writer) Counter

func (w *Writer) Counter(name, help string, samples ...Sample)

Counter writes a counter family. See the package documentation for what happens when a name or label is invalid or the family already exists.

func (*Writer) Err

func (w *Writer) Err() error

Err returns the first problem recorded by Counter or Gauge, or nil. Every problem also appears as a comment line at the end of the scrape.

func (*Writer) Gauge

func (w *Writer) Gauge(name, help string, samples ...Sample)

Gauge writes a gauge family. See the package documentation for what happens when a name or label is invalid or the family already exists.

func (*Writer) WriteTo

func (w *Writer) WriteTo(dst io.Writer) (int64, error)

WriteTo writes the scrape to dst.

Directories

Path Synopsis
internal
promcheck module

Jump to

Keyboard shortcuts

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