mirror of
https://github.com/luxfi/metric.git
synced 2026-10-30 16:19:50 +00:00
Every exposition entry point returned an http.Handler -- HandlerFor, HandlerForWithOpts, Handler, HTTPHandler, NewHTTPHandler, InstrumentMetricHandler -- so a caller whose transport is not net/http had one option: adapt the handler, and serve its own metrics through a foreign router. Four services in the estate do exactly that, and the only alternative was for each to reimplement the timeout, the error policy and the content type a scraper reads. Scrape renders the exposition as a value: the status, the headers, the body. ScrapeTimeout reads the deadline off any header lookup, so the two spellings a scraper may use are known in one place. A caller on any transport writes those three fields. HandlerForWithOpts is now a writer over Scrape and decides nothing itself, so the two cannot answer differently. parseScrapeTimeout went with it -- it was the same rule, written twice. The evidence is the order it was taken in: TestScrapeAndTheHandlerAnswerAlike drives seven paths -- a plain scrape, an empty one, both timeout spellings, an unparseable one, and a gather failure under each error policy -- and passed against the ORIGINAL handler before it was reimplemented. So the value matches what the handler used to write, rather than matching a handler that now derives from it.
106 lines
3.6 KiB
Go
106 lines
3.6 KiB
Go
// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package metric
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Exposition is one rendered scrape: the status to answer with, the headers a
|
|
// scraper reads, and the body. Rendering it is separate from writing it, so a
|
|
// caller writes it the way its own transport writes.
|
|
//
|
|
// Every handler in this package is a writer over [Scrape]. A caller whose
|
|
// transport is not net/http — a router that hands out its own context and
|
|
// writes bytes itself — uses Scrape directly and writes the three fields,
|
|
// rather than reimplementing the timeout, the error policy and the content
|
|
// type a scraper expects.
|
|
type Exposition struct {
|
|
Status int
|
|
Header map[string]string
|
|
Body []byte
|
|
}
|
|
|
|
// Scrape renders the exposition for gatherer under opts.
|
|
//
|
|
// timeout is the deadline the scraper asked for; zero means none. A caller on
|
|
// net/http reads it with [ScrapeTimeout]; any other transport passes its own
|
|
// header lookup to the same function, so the two cannot disagree about which
|
|
// header carries it.
|
|
//
|
|
// A gather error is answered per opts.ErrorHandling: Continue logs and renders
|
|
// whatever families were gathered, anything else renders the 500 that
|
|
// http.Error writes — the same status, content type, nosniff and trailing
|
|
// newline — so a caller writing this value answers exactly as the handler did.
|
|
func Scrape(ctx context.Context, gatherer Gatherer, opts HandlerOpts, timeout time.Duration) Exposition {
|
|
if timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
}
|
|
|
|
families, err := gatherWithContext(ctx, gatherer)
|
|
if err != nil {
|
|
if opts.ErrorHandling != HandlerErrorHandlingContinue {
|
|
return refusal("metrics gather error")
|
|
}
|
|
if opts.ErrorLog != nil {
|
|
opts.ErrorLog.Println("metrics gather error:", err)
|
|
}
|
|
}
|
|
|
|
var b bytes.Buffer
|
|
if err := EncodeText(&b, families); err != nil {
|
|
if opts.ErrorHandling == HandlerErrorHandlingContinue && opts.ErrorLog != nil {
|
|
opts.ErrorLog.Println("metrics encode error:", err)
|
|
return Exposition{Status: 200, Header: expositionHeader(), Body: b.Bytes()}
|
|
}
|
|
return refusal("metrics encode error")
|
|
}
|
|
|
|
return Exposition{Status: 200, Header: expositionHeader(), Body: b.Bytes()}
|
|
}
|
|
|
|
// ScrapeTimeout reads the deadline a scraper asked for. get is the caller's own
|
|
// header lookup — http.Header.Get, or whatever the transport offers — so this
|
|
// is the one place that knows which headers carry it.
|
|
func ScrapeTimeout(get func(string) string) time.Duration {
|
|
v := get("X-Scrape-Timeout-Seconds")
|
|
if v == "" {
|
|
v = get("X-Prometheus-Scrape-Timeout-Seconds")
|
|
}
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
seconds, err := strconv.ParseFloat(v, 64)
|
|
if err != nil || seconds <= 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(seconds * float64(time.Second))
|
|
}
|
|
|
|
// expositionHeader is the content type a Prometheus scraper parses. The version
|
|
// is part of the media type, not decoration: a scraper reads it to choose a
|
|
// parser.
|
|
func expositionHeader() map[string]string {
|
|
return map[string]string{"Content-Type": "text/plain; version=0.0.4; charset=utf-8"}
|
|
}
|
|
|
|
// refusal renders what http.Error writes, so a caller on any transport answers
|
|
// a failed scrape identically: the plain-text type, the nosniff that stops a
|
|
// browser sniffing the message as markup, and the trailing newline.
|
|
func refusal(msg string) Exposition {
|
|
return Exposition{
|
|
Status: 500,
|
|
Header: map[string]string{
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
Body: []byte(msg + "\n"),
|
|
}
|
|
}
|