mirror of
https://github.com/fatedier/frp.git
synced 2026-10-20 21:43:28 +08:00
feat(client): serve Go runtime metrics on metricsServer (#5579)
Add pkg/metrics/text, a small Prometheus text-format writer, and use it for frpc's /metrics. The endpoint lives only on metricsServer and is covered by its Basic Auth, like the other endpoints there. The package emits the Go runtime families and, on Linux, the process families read from /proc. Their names, types and help strings match client_golang's default collectors, so existing Go runtime dashboards keep working. It deliberately emits no frp_ business metrics and none of client_golang's promhttp_ handler metrics. Writing the subset ourselves keeps client_golang and protobuf out of the frpc binary; both are still used by frps and by the tests here.
This commit is contained in:
@@ -638,7 +638,7 @@ metricsServer.password = "change-me"
|
||||
|
||||
Scrape `http://127.0.0.1:7501/metrics` using these Basic Auth credentials. This automatically enables frps business metrics, even when `webServer` is disabled and `enablePrometheus` is unset. Credentials and TLS are independent of `webServer`; when credentials are configured, both `/metrics` and profiling endpoints require authentication. `/healthz` remains anonymous.
|
||||
|
||||
Set `metricsServer.pprofEnable = true` to expose `/debug/pprof/` and profiling endpoints on this listener. `/debug/pprof/cmdline` is excluded. frpc supports the same health and profiling settings, but does not provide `/metrics` yet.
|
||||
Set `metricsServer.pprofEnable = true` to expose `/debug/pprof/` and profiling endpoints on this listener. `/debug/pprof/cmdline` is excluded. frpc serves `/metrics` on this listener too, with Go runtime metrics only: no proxy, traffic or client data, and the same set of metrics on every platform.
|
||||
|
||||
The `/healthz`, `/metrics`, and pprof endpoints on `webServer` are deprecated but remain available. `enablePrometheus = true` still enables the legacy frps `/metrics` endpoint on the dashboard listener. Migrate health probes, Prometheus scrapes, and profiling to `metricsServer`; the dashboard and admin APIs continue to use `webServer`.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
* frpc can verify server certificates using system CAs, optionally combined with CAs from `trustedCaFile`.
|
||||
* `frpc reload` now refreshes certificate files for unchanged `https2http` and `https2https` proxies without interrupting existing connections. Failed refreshes keep the previous certificate and report the error in proxy status and logs while reload still succeeds.
|
||||
* Added `metricsServer` to both frps and frpc, a separate listener for health checks and optional profiling that does not expose the dashboard or the admin API. It is disabled unless `metricsServer.port` is set, defaults to `127.0.0.1`, and has its own credentials and TLS. In frps, setting the port also serves `/metrics` with both runtime and business metrics, so `enablePrometheus` is not needed there.
|
||||
* Added `metricsServer` to both frps and frpc, a separate listener for health checks, metrics and optional profiling that does not expose the dashboard or the admin API. It is disabled unless `metricsServer.port` is set, defaults to `127.0.0.1`, and has its own credentials and TLS. Setting the port serves `/metrics` on both: frps exposes runtime and business metrics, so `enablePrometheus` is not needed there, and frpc exposes Go runtime metrics only, without linking an extra metrics library.
|
||||
|
||||
The `/healthz`, `/metrics` and pprof endpoints on `webServer`, and `enablePrometheus`, still work but are deprecated: configure `metricsServer` instead. They will be removed in a later release.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -82,10 +83,19 @@ func TestServiceMetricsServerLifecycle(t *testing.T) {
|
||||
assertLifecycleAdminPort(t, address, true)
|
||||
}()
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
for path, status := range map[string]int{"/healthz": http.StatusOK, "/metrics": http.StatusNotFound} {
|
||||
resp, err := client.Get("http://" + address + path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, status, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
}
|
||||
resp, err := client.Get("http://" + address + "/healthz")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
// frpc serves Go runtime metrics only, and no credentials are configured
|
||||
// here, so this endpoint is reachable without authentication.
|
||||
resp, err = client.Get("http://" + address + "/metrics")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Contains(t, string(body), "go_goroutines")
|
||||
require.NotContains(t, string(body), "frp_")
|
||||
}
|
||||
|
||||
+10
-1
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
"github.com/fatedier/frp/pkg/metrics/text"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
@@ -199,7 +200,15 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
}
|
||||
webServer = ws
|
||||
}
|
||||
metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, nil)
|
||||
// frpc exposes Go runtime metrics only. They are written by pkg/metrics/text
|
||||
// so that client_golang and protobuf stay out of the frpc binary.
|
||||
var metricsHandler http.Handler
|
||||
if options.Common.MetricsServer.Port > 0 {
|
||||
registry := text.NewRegistry()
|
||||
registry.RegisterRuntime()
|
||||
metricsHandler = registry.Handler()
|
||||
}
|
||||
metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, metricsHandler)
|
||||
if err != nil {
|
||||
if webServer != nil {
|
||||
_ = webServer.Close()
|
||||
|
||||
@@ -83,7 +83,8 @@ webServer.password = "admin"
|
||||
# Enable golang pprof handlers in admin listener.
|
||||
webServer.pprofEnable = false
|
||||
|
||||
# Separate health and optional profiling listener. frpc does not serve /metrics.
|
||||
# Separate health, metrics and optional profiling listener.
|
||||
# /metrics serves Go runtime metrics only (no proxy or traffic data).
|
||||
# Port 0 disables it. The default address only accepts local connections.
|
||||
metricsServer.addr = "127.0.0.1"
|
||||
metricsServer.port = 0
|
||||
|
||||
@@ -15,6 +15,8 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.0
|
||||
github.com/pires/go-proxyproto v0.15.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/prometheus/client_model v0.5.0
|
||||
github.com/prometheus/common v0.48.0
|
||||
github.com/quic-go/quic-go v0.60.0
|
||||
github.com/rodaine/table v1.2.0
|
||||
github.com/samber/lo v1.47.0
|
||||
@@ -54,8 +56,6 @@ require (
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/templexxx/cpu v0.1.1 // indirect
|
||||
github.com/templexxx/xorsimd v0.4.3 // indirect
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package text
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// collector returns the families of one scrape.
|
||||
type collector func() []family
|
||||
|
||||
// familiesPerCollector sizes the gather buffer; the runtime collector is the
|
||||
// only one today and emits the 22 memstats families plus a handful of others.
|
||||
const familiesPerCollector = 32
|
||||
|
||||
// Registry holds the collectors exposed on one listener.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
collectors []collector
|
||||
}
|
||||
|
||||
// NewRegistry returns a registry with no collectors.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
}
|
||||
|
||||
// RegisterRuntime registers the Go runtime collector.
|
||||
func (r *Registry) RegisterRuntime() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.collectors = append(r.collectors, runtimeFamilies)
|
||||
}
|
||||
|
||||
// WritePrometheus writes every collected family in the text exposition format.
|
||||
func (r *Registry) WritePrometheus(w io.Writer) error {
|
||||
return encode(w, r.gather())
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler serving the registry.
|
||||
func (r *Registry) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Encode into memory first so that a failure never sends a truncated body.
|
||||
var b strings.Builder
|
||||
if err := r.WritePrometheus(&b); err != nil {
|
||||
http.Error(w, "metrics unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ContentType)
|
||||
_, _ = io.WriteString(w, b.String())
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Registry) gather() []family {
|
||||
r.mu.RLock()
|
||||
collectors := append([]collector(nil), r.collectors...)
|
||||
r.mu.RUnlock()
|
||||
|
||||
out := make([]family, 0, len(collectors)*familiesPerCollector)
|
||||
for _, c := range collectors {
|
||||
out = append(out, c()...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package text
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"runtime/metrics"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memstats is one go_memstats_* family. The values are read the same way
|
||||
// client_golang reads them since Go 1.17: from runtime/metrics, which does not
|
||||
// stop the world, and then mapped onto runtime.MemStats fields.
|
||||
type memstats struct {
|
||||
name string
|
||||
help string
|
||||
typ metricType
|
||||
get func(*runtime.MemStats) float64
|
||||
}
|
||||
|
||||
// memstatsFamilies matches the go_memstats_* families of client_golang's default
|
||||
// registry, including their help strings and types.
|
||||
var memstatsFamilies = []memstats{
|
||||
{
|
||||
name: "go_memstats_alloc_bytes",
|
||||
help: "Number of bytes allocated and still in use.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Alloc) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_alloc_bytes_total",
|
||||
help: "Total number of bytes allocated, even if freed.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.TotalAlloc) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_buck_hash_sys_bytes",
|
||||
help: "Number of bytes used by the profiling bucket hash table.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.BuckHashSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_frees_total",
|
||||
help: "Total number of frees.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Frees) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_gc_sys_bytes",
|
||||
help: "Number of bytes used for garbage collection system metadata.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.GCSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_alloc_bytes",
|
||||
help: "Number of heap bytes allocated and still in use.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapAlloc) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_idle_bytes",
|
||||
help: "Number of heap bytes waiting to be used.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapIdle) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_inuse_bytes",
|
||||
help: "Number of heap bytes that are in use.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_objects",
|
||||
help: "Number of allocated objects.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapObjects) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_released_bytes",
|
||||
help: "Number of heap bytes released to OS.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapReleased) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_heap_sys_bytes",
|
||||
help: "Number of heap bytes obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_lookups_total",
|
||||
help: "Total number of pointer lookups.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Lookups) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mallocs_total",
|
||||
help: "Total number of mallocs.",
|
||||
typ: typeCounter,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Mallocs) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mcache_inuse_bytes",
|
||||
help: "Number of bytes in use by mcache structures.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mcache_sys_bytes",
|
||||
help: "Number of bytes used for mcache structures obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mspan_inuse_bytes",
|
||||
help: "Number of bytes in use by mspan structures.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_mspan_sys_bytes",
|
||||
help: "Number of bytes used for mspan structures obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_next_gc_bytes",
|
||||
help: "Number of heap bytes when next garbage collection will take place.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.NextGC) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_other_sys_bytes",
|
||||
help: "Number of bytes used for other system allocations.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.OtherSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_stack_inuse_bytes",
|
||||
help: "Number of bytes in use by the stack allocator.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackInuse) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_stack_sys_bytes",
|
||||
help: "Number of bytes obtained from system for stack allocator.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackSys) },
|
||||
},
|
||||
{
|
||||
name: "go_memstats_sys_bytes",
|
||||
help: "Number of bytes obtained from system.",
|
||||
typ: typeGauge,
|
||||
get: func(ms *runtime.MemStats) float64 { return float64(ms.Sys) },
|
||||
},
|
||||
}
|
||||
|
||||
// The runtime/metrics names below are the ones client_golang v1.19.1 reads to
|
||||
// build the go_memstats_* families. See prometheus/go_collector_latest.go in
|
||||
// that module (Apache-2.0).
|
||||
const (
|
||||
rmGCHeapTinyAllocsObjects = "/gc/heap/tiny/allocs:objects"
|
||||
rmGCHeapAllocsObjects = "/gc/heap/allocs:objects"
|
||||
rmGCHeapFreesObjects = "/gc/heap/frees:objects"
|
||||
rmGCHeapAllocsBytes = "/gc/heap/allocs:bytes"
|
||||
rmGCHeapObjects = "/gc/heap/objects:objects"
|
||||
rmGCHeapGoalBytes = "/gc/heap/goal:bytes"
|
||||
rmMemoryClassesTotalBytes = "/memory/classes/total:bytes"
|
||||
rmMemoryClassesHeapObjectsBytes = "/memory/classes/heap/objects:bytes"
|
||||
rmMemoryClassesHeapUnusedBytes = "/memory/classes/heap/unused:bytes"
|
||||
rmMemoryClassesHeapReleasedBytes = "/memory/classes/heap/released:bytes"
|
||||
rmMemoryClassesHeapFreeBytes = "/memory/classes/heap/free:bytes"
|
||||
rmMemoryClassesHeapStacksBytes = "/memory/classes/heap/stacks:bytes"
|
||||
rmMemoryClassesOSStacksBytes = "/memory/classes/os-stacks:bytes"
|
||||
rmMemoryClassesMetadataMSpanInuseBytes = "/memory/classes/metadata/mspan/inuse:bytes"
|
||||
rmMemoryClassesMetadataMSpanFreeBytes = "/memory/classes/metadata/mspan/free:bytes"
|
||||
rmMemoryClassesMetadataMCacheInuseBytes = "/memory/classes/metadata/mcache/inuse:bytes"
|
||||
rmMemoryClassesMetadataMCacheFreeBytes = "/memory/classes/metadata/mcache/free:bytes"
|
||||
rmMemoryClassesProfilingBucketsBytes = "/memory/classes/profiling/buckets:bytes"
|
||||
rmMemoryClassesMetadataOtherBytes = "/memory/classes/metadata/other:bytes"
|
||||
rmMemoryClassesOtherBytes = "/memory/classes/other:bytes"
|
||||
)
|
||||
|
||||
// memoryNames lists every runtime/metrics name memStatsFromRM reads.
|
||||
var memoryNames = []string{
|
||||
rmGCHeapTinyAllocsObjects,
|
||||
rmGCHeapAllocsObjects,
|
||||
rmGCHeapFreesObjects,
|
||||
rmGCHeapAllocsBytes,
|
||||
rmGCHeapObjects,
|
||||
rmGCHeapGoalBytes,
|
||||
rmMemoryClassesTotalBytes,
|
||||
rmMemoryClassesHeapObjectsBytes,
|
||||
rmMemoryClassesHeapUnusedBytes,
|
||||
rmMemoryClassesHeapReleasedBytes,
|
||||
rmMemoryClassesHeapFreeBytes,
|
||||
rmMemoryClassesHeapStacksBytes,
|
||||
rmMemoryClassesOSStacksBytes,
|
||||
rmMemoryClassesMetadataMSpanInuseBytes,
|
||||
rmMemoryClassesMetadataMSpanFreeBytes,
|
||||
rmMemoryClassesMetadataMCacheInuseBytes,
|
||||
rmMemoryClassesMetadataMCacheFreeBytes,
|
||||
rmMemoryClassesProfilingBucketsBytes,
|
||||
rmMemoryClassesMetadataOtherBytes,
|
||||
rmMemoryClassesOtherBytes,
|
||||
}
|
||||
|
||||
// memorySamples holds the sample buffer of one scrape and the index that maps a
|
||||
// runtime/metrics name to its slot.
|
||||
type memorySamples struct {
|
||||
samples []metrics.Sample
|
||||
index map[string]int
|
||||
}
|
||||
|
||||
// memStatsSnapshot takes the Go memory statistics frpc reports. It reads the
|
||||
// runtime/metrics package instead of calling runtime.ReadMemStats, which stops
|
||||
// the world on every call, and memStatsFromRM maps the samples onto the old
|
||||
// field names.
|
||||
//
|
||||
// The sample buffer is reused between scrapes, so concurrent scrapes take turns:
|
||||
// runtime/metrics.Read writes into the samples it is handed.
|
||||
func memStatsSnapshot() runtime.MemStats {
|
||||
memoryMu.Lock()
|
||||
defer memoryMu.Unlock()
|
||||
|
||||
if memoryBuffer == nil {
|
||||
memoryBuffer = newMemorySamples()
|
||||
}
|
||||
// metrics.Read indexes into the slice, so an empty buffer has to be skipped.
|
||||
if len(memoryBuffer.samples) > 0 {
|
||||
metrics.Read(memoryBuffer.samples)
|
||||
}
|
||||
|
||||
var ms runtime.MemStats
|
||||
memStatsFromRM(&ms, memoryBuffer.value)
|
||||
return ms
|
||||
}
|
||||
|
||||
var (
|
||||
memoryMu sync.Mutex
|
||||
memoryBuffer *memorySamples
|
||||
)
|
||||
|
||||
// newMemorySamples builds the sample buffer from the names this runtime supports.
|
||||
// A name the runtime does not know is left out and reads as zero, which is what
|
||||
// client_golang does as well.
|
||||
func newMemorySamples() *memorySamples {
|
||||
supported := make(map[string]struct{}, len(memoryNames))
|
||||
for _, d := range metrics.All() {
|
||||
supported[d.Name] = struct{}{}
|
||||
}
|
||||
s := &memorySamples{index: make(map[string]int, len(memoryNames))}
|
||||
for _, name := range memoryNames {
|
||||
if _, ok := supported[name]; !ok {
|
||||
continue
|
||||
}
|
||||
s.index[name] = len(s.samples)
|
||||
s.samples = append(s.samples, metrics.Sample{Name: name})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// value returns the sampled value of name, or zero when the runtime does not
|
||||
// expose it.
|
||||
func (s *memorySamples) value(name string) uint64 {
|
||||
i, ok := s.index[name]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return s.samples[i].Value.Uint64()
|
||||
}
|
||||
|
||||
// memStatsFromRM maps runtime/metrics samples onto a runtime.MemStats, using the
|
||||
// same field combinations as client_golang v1.19.1, so the go_memstats_* values
|
||||
// match the ones the official collector reports for the same process. GCCPUFraction
|
||||
// is left at zero, as client_golang omits it too.
|
||||
func memStatsFromRM(ms *runtime.MemStats, lookup func(string) uint64) {
|
||||
// MemStats counts tiny allocations in both Mallocs and Frees. client_golang
|
||||
// keeps that behavior because Mallocs-Frees is then a live object count.
|
||||
tinyAllocs := lookup(rmGCHeapTinyAllocsObjects)
|
||||
ms.Mallocs = lookup(rmGCHeapAllocsObjects) + tinyAllocs
|
||||
ms.Frees = lookup(rmGCHeapFreesObjects) + tinyAllocs
|
||||
|
||||
ms.TotalAlloc = lookup(rmGCHeapAllocsBytes)
|
||||
ms.Sys = lookup(rmMemoryClassesTotalBytes)
|
||||
ms.Lookups = 0 // Already always zero.
|
||||
ms.HeapAlloc = lookup(rmMemoryClassesHeapObjectsBytes)
|
||||
ms.Alloc = ms.HeapAlloc
|
||||
ms.HeapInuse = ms.HeapAlloc + lookup(rmMemoryClassesHeapUnusedBytes)
|
||||
ms.HeapReleased = lookup(rmMemoryClassesHeapReleasedBytes)
|
||||
ms.HeapIdle = ms.HeapReleased + lookup(rmMemoryClassesHeapFreeBytes)
|
||||
ms.HeapSys = ms.HeapInuse + ms.HeapIdle
|
||||
ms.HeapObjects = lookup(rmGCHeapObjects)
|
||||
ms.StackInuse = lookup(rmMemoryClassesHeapStacksBytes)
|
||||
ms.StackSys = ms.StackInuse + lookup(rmMemoryClassesOSStacksBytes)
|
||||
ms.MSpanInuse = lookup(rmMemoryClassesMetadataMSpanInuseBytes)
|
||||
ms.MSpanSys = ms.MSpanInuse + lookup(rmMemoryClassesMetadataMSpanFreeBytes)
|
||||
ms.MCacheInuse = lookup(rmMemoryClassesMetadataMCacheInuseBytes)
|
||||
ms.MCacheSys = ms.MCacheInuse + lookup(rmMemoryClassesMetadataMCacheFreeBytes)
|
||||
ms.BuckHashSys = lookup(rmMemoryClassesProfilingBucketsBytes)
|
||||
ms.GCSys = lookup(rmMemoryClassesMetadataOtherBytes)
|
||||
ms.OtherSys = lookup(rmMemoryClassesOtherBytes)
|
||||
ms.NextGC = lookup(rmGCHeapGoalBytes)
|
||||
}
|
||||
|
||||
// gcQuantileLabels matches the quantiles client_golang reports in the
|
||||
// go_gc_duration_seconds summary.
|
||||
var gcQuantileLabels = []string{"0", "0.25", "0.5", "0.75", "1"}
|
||||
|
||||
// runtimeFamilies reads the runtime once per scrape rather than once per metric.
|
||||
func runtimeFamilies() []family {
|
||||
ms := memStatsSnapshot()
|
||||
|
||||
out := make([]family, 0, len(memstatsFamilies)+5)
|
||||
for _, m := range memstatsFamilies {
|
||||
out = append(out, family{
|
||||
name: m.name,
|
||||
help: m.help,
|
||||
typ: m.typ,
|
||||
samples: []sample{{value: m.get(&ms)}},
|
||||
})
|
||||
}
|
||||
|
||||
// debug.ReadGCStats only takes the mheap lock; it does not stop the world.
|
||||
var gc debug.GCStats
|
||||
gc.PauseQuantiles = make([]time.Duration, len(gcQuantileLabels))
|
||||
debug.ReadGCStats(&gc)
|
||||
|
||||
gcSamples := make([]sample, 0, len(gcQuantileLabels)+2)
|
||||
for i, q := range gcQuantileLabels {
|
||||
var pause time.Duration
|
||||
if i < len(gc.PauseQuantiles) {
|
||||
pause = gc.PauseQuantiles[i]
|
||||
}
|
||||
gcSamples = append(gcSamples, sample{
|
||||
labels: []label{{name: "quantile", value: q}},
|
||||
value: pause.Seconds(),
|
||||
})
|
||||
}
|
||||
gcSamples = append(gcSamples,
|
||||
sample{name: "go_gc_duration_seconds_sum", value: gc.PauseTotal.Seconds()},
|
||||
sample{name: "go_gc_duration_seconds_count", value: float64(gc.NumGC)},
|
||||
)
|
||||
|
||||
return append(out,
|
||||
family{
|
||||
name: "go_gc_duration_seconds",
|
||||
help: "A summary of the pause duration of garbage collection cycles.",
|
||||
typ: typeSummary,
|
||||
samples: gcSamples,
|
||||
},
|
||||
family{
|
||||
name: "go_goroutines",
|
||||
help: "Number of goroutines that currently exist.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{value: float64(runtime.NumGoroutine())}},
|
||||
},
|
||||
family{
|
||||
name: "go_info",
|
||||
help: "Information about the Go environment.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{labels: []label{{name: "version", value: runtime.Version()}}, value: 1}},
|
||||
},
|
||||
family{
|
||||
name: "go_memstats_last_gc_time_seconds",
|
||||
help: "Number of seconds since 1970 of last garbage collection.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{
|
||||
value: float64(gc.LastGC.UnixNano()) / float64(time.Second),
|
||||
}},
|
||||
},
|
||||
family{
|
||||
name: "go_threads",
|
||||
help: "Number of OS threads created.",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{value: float64(threadCount())}},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func threadCount() int {
|
||||
n, _ := runtime.ThreadCreateProfile(nil)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package text
|
||||
|
||||
import (
|
||||
"math"
|
||||
"runtime"
|
||||
"runtime/metrics"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMemStatsMapping pins the runtime/metrics to MemStats mapping field by
|
||||
// field. The sample values are made up so that every combination shows up: tiny
|
||||
// allocations are added to both Mallocs and Frees, and the combined fields
|
||||
// (HeapInuse, HeapIdle, HeapSys, StackSys, MSpanSys, MCacheSys) add their parts.
|
||||
func TestMemStatsMapping(t *testing.T) {
|
||||
samples := map[string]uint64{
|
||||
rmGCHeapTinyAllocsObjects: 3,
|
||||
rmGCHeapAllocsObjects: 100,
|
||||
rmGCHeapFreesObjects: 40,
|
||||
rmGCHeapAllocsBytes: 1 << 20,
|
||||
rmGCHeapObjects: 900,
|
||||
rmGCHeapGoalBytes: 4 << 20,
|
||||
rmMemoryClassesTotalBytes: 50 << 20,
|
||||
rmMemoryClassesHeapObjectsBytes: 2 << 20,
|
||||
rmMemoryClassesHeapUnusedBytes: 3 << 20,
|
||||
rmMemoryClassesHeapReleasedBytes: 5 << 20,
|
||||
rmMemoryClassesHeapFreeBytes: 7 << 20,
|
||||
rmMemoryClassesHeapStacksBytes: 1 << 20,
|
||||
rmMemoryClassesOSStacksBytes: 2 << 20,
|
||||
rmMemoryClassesMetadataMSpanInuseBytes: 1 << 19,
|
||||
rmMemoryClassesMetadataMSpanFreeBytes: 1 << 18,
|
||||
rmMemoryClassesMetadataMCacheInuseBytes: 1 << 17,
|
||||
rmMemoryClassesMetadataMCacheFreeBytes: 1 << 16,
|
||||
rmMemoryClassesProfilingBucketsBytes: 1 << 15,
|
||||
rmMemoryClassesMetadataOtherBytes: 1 << 14,
|
||||
rmMemoryClassesOtherBytes: 1 << 13,
|
||||
}
|
||||
lookup := func(name string) uint64 { return samples[name] }
|
||||
|
||||
var ms runtime.MemStats
|
||||
memStatsFromRM(&ms, lookup)
|
||||
|
||||
// Expectations are keyed by family name, so this also checks that each family
|
||||
// reads the field the mapping fills.
|
||||
want := map[string]float64{
|
||||
"go_memstats_alloc_bytes": 2 << 20,
|
||||
"go_memstats_alloc_bytes_total": 1 << 20,
|
||||
"go_memstats_buck_hash_sys_bytes": 1 << 15,
|
||||
"go_memstats_frees_total": 43, // 40 frees + 3 tiny allocs
|
||||
"go_memstats_gc_sys_bytes": 1 << 14,
|
||||
"go_memstats_heap_alloc_bytes": 2 << 20,
|
||||
"go_memstats_heap_idle_bytes": 12 << 20, // released + free
|
||||
"go_memstats_heap_inuse_bytes": 5 << 20, // objects + unused
|
||||
"go_memstats_heap_objects": 900,
|
||||
"go_memstats_heap_released_bytes": 5 << 20,
|
||||
"go_memstats_heap_sys_bytes": 17 << 20, // inuse + idle
|
||||
"go_memstats_lookups_total": 0,
|
||||
"go_memstats_mallocs_total": 103, // 100 mallocs + 3 tiny allocs
|
||||
"go_memstats_mcache_inuse_bytes": 1 << 17,
|
||||
"go_memstats_mcache_sys_bytes": 3 << 16, // inuse + free
|
||||
"go_memstats_mspan_inuse_bytes": 1 << 19,
|
||||
"go_memstats_mspan_sys_bytes": 3 << 18, // inuse + free
|
||||
"go_memstats_next_gc_bytes": 4 << 20,
|
||||
"go_memstats_other_sys_bytes": 1 << 13,
|
||||
"go_memstats_stack_inuse_bytes": 1 << 20,
|
||||
"go_memstats_stack_sys_bytes": 3 << 20, // inuse + os-stacks
|
||||
"go_memstats_sys_bytes": 50 << 20,
|
||||
}
|
||||
require.Len(t, want, len(memstatsFamilies), "every family needs an expectation")
|
||||
for _, m := range memstatsFamilies {
|
||||
expected, ok := want[m.name]
|
||||
require.True(t, ok, "%s has no expectation", m.name)
|
||||
require.Equal(t, expected, m.get(&ms), "%s", m.name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemorySamplesMissingName checks that a runtime which does not expose a
|
||||
// name reads it as zero instead of failing, the way client_golang behaves.
|
||||
func TestMemorySamplesMissingName(t *testing.T) {
|
||||
s := &memorySamples{
|
||||
samples: []metrics.Sample{{Name: rmGCHeapObjects}},
|
||||
index: map[string]int{rmGCHeapObjects: 0},
|
||||
}
|
||||
require.Equal(t, uint64(0), s.value(rmMemoryClassesTotalBytes))
|
||||
}
|
||||
|
||||
// TestMemStatsSameOrderAsClientGolang compares what frpc reports with what
|
||||
// client_golang reads from the same process at about the same time. The check is
|
||||
// deliberately loose: the two collectors sample the runtime one after the other,
|
||||
// so counters move by whatever the test itself allocates in between, and an exact
|
||||
// comparison would flake.
|
||||
func TestMemStatsSameOrderAsClientGolang(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(collectors.NewGoCollector())
|
||||
mfs, err := reg.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
ours := parseFamilies(t, gather(t))
|
||||
checked := 0
|
||||
for _, mf := range mfs {
|
||||
name := mf.GetName()
|
||||
if !strings.HasPrefix(name, "go_memstats_") {
|
||||
continue
|
||||
}
|
||||
if name == "go_memstats_last_gc_time_seconds" {
|
||||
// A collection that starts between the two reads jumps this value from
|
||||
// zero to the current time; that is not a difference in behavior.
|
||||
continue
|
||||
}
|
||||
want, ok := ours[name]
|
||||
require.True(t, ok, "%s is missing", name)
|
||||
reference := metricValue(t, mf.GetMetric()[0])
|
||||
got := metricValue(t, want.GetMetric()[0])
|
||||
require.True(t, sameOrder(reference, got),
|
||||
"%s differs too much: ours %v, client_golang %v", name, got, reference)
|
||||
checked++
|
||||
}
|
||||
require.Greater(t, checked, 15, "comparison covered too few families")
|
||||
}
|
||||
|
||||
func metricValue(t *testing.T, m *dto.Metric) float64 {
|
||||
t.Helper()
|
||||
if g := m.GetGauge(); g != nil {
|
||||
return g.GetValue()
|
||||
}
|
||||
if c := m.GetCounter(); c != nil {
|
||||
return c.GetValue()
|
||||
}
|
||||
t.Fatalf("unexpected metric type for %s", m.String())
|
||||
return 0
|
||||
}
|
||||
|
||||
// sameOrder reports whether two readings of the same quantity are close enough
|
||||
// that they cannot be different quantities: equal, within 64 KiB (drift while
|
||||
// the test allocates), or within a factor of ten.
|
||||
func sameOrder(a, b float64) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if math.Abs(a-b) <= 1<<16 {
|
||||
return true
|
||||
}
|
||||
if a <= 0 || b <= 0 {
|
||||
return false
|
||||
}
|
||||
ratio := a / b
|
||||
return ratio > 0.1 && ratio < 10
|
||||
}
|
||||
|
||||
// TestConcurrentScrapes covers the shared runtime/metrics sample buffer: scrapes
|
||||
// run on the listener's goroutines and must not race. Run with -race to check it.
|
||||
func TestConcurrentScrapes(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.RegisterRuntime()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
for range 20 {
|
||||
var b strings.Builder
|
||||
if err := reg.WritePrometheus(&b); err != nil {
|
||||
t.Errorf("scrape failed: %v", err)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(b.String(), "go_memstats_heap_objects") {
|
||||
t.Error("scrape is missing go_memstats_heap_objects")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package text writes Prometheus metrics in the text exposition format.
|
||||
//
|
||||
// frp uses it for frpc, which only exposes Go runtime metrics. Implementing
|
||||
// that subset here keeps client_golang and its protobuf dependency out of the
|
||||
// frpc binary; frps keeps using client_golang, which also gives it the
|
||||
// business metrics and the HTTP handler instrumentation.
|
||||
package text
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContentType is the content type of the text exposition format.
|
||||
const ContentType = "text/plain; version=0.0.4; charset=utf-8"
|
||||
|
||||
// metricType is the Prometheus metric type.
|
||||
type metricType string
|
||||
|
||||
const (
|
||||
typeCounter metricType = "counter"
|
||||
typeGauge metricType = "gauge"
|
||||
typeSummary metricType = "summary"
|
||||
)
|
||||
|
||||
type label struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// sample is one time series. name is empty for plain families, where the
|
||||
// family name is used; summaries set it to carry the _sum and _count suffixes.
|
||||
type sample struct {
|
||||
name string
|
||||
labels []label
|
||||
value float64
|
||||
}
|
||||
|
||||
// family is one metric family ready to be encoded.
|
||||
type family struct {
|
||||
name string
|
||||
help string
|
||||
typ metricType
|
||||
samples []sample
|
||||
}
|
||||
|
||||
// encode writes families in the order they are given.
|
||||
func encode(w io.Writer, families []family) error {
|
||||
for _, f := range families {
|
||||
if _, err := fmt.Fprintf(w, "# HELP %s %s\n", f.name, escapeHelp(f.help)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "# TYPE %s %s\n", f.name, f.typ); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range f.samples {
|
||||
if err := encodeSample(w, f.name, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSample(w io.Writer, familyName string, s sample) error {
|
||||
name := s.name
|
||||
if name == "" {
|
||||
name = familyName
|
||||
}
|
||||
if _, err := io.WriteString(w, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s.labels) > 0 {
|
||||
if err := encodeLabels(w, s.labels); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintf(w, " %s\n", formatValue(s.value))
|
||||
return err
|
||||
}
|
||||
|
||||
func encodeLabels(w io.Writer, labels []label) error {
|
||||
if _, err := io.WriteString(w, "{"); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, l := range labels {
|
||||
if i > 0 {
|
||||
if _, err := io.WriteString(w, ","); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, `%s="%s"`, l.name, escapeLabelValue(l.value)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := io.WriteString(w, "}")
|
||||
return err
|
||||
}
|
||||
|
||||
// escapeLabelValue applies the three escapes the text format defines for
|
||||
// label values.
|
||||
func escapeLabelValue(v string) string {
|
||||
if !strings.ContainsAny(v, "\\\"\n") {
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(v) + 8)
|
||||
for _, r := range v {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeHelp applies the two escapes the text format defines for help text.
|
||||
func escapeHelp(v string) string {
|
||||
if !strings.ContainsAny(v, "\\\n") {
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(v) + 8)
|
||||
for _, r := range v {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatValue renders a float the way the Prometheus text format does, so a
|
||||
// sample reads the same whichever implementation produced it.
|
||||
func formatValue(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package text
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// gather returns what frpc would serve.
|
||||
func gather(t *testing.T) string {
|
||||
t.Helper()
|
||||
reg := NewRegistry()
|
||||
reg.RegisterRuntime()
|
||||
var b strings.Builder
|
||||
require.NoError(t, reg.WritePrometheus(&b))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseFamilies decodes the text exposition format with the Prometheus parser,
|
||||
// so anything a scraper would reject fails the test.
|
||||
func parseFamilies(t *testing.T, body string) map[string]*dto.MetricFamily {
|
||||
t.Helper()
|
||||
parser := expfmt.TextParser{}
|
||||
families, err := parser.TextToMetricFamilies(strings.NewReader(body))
|
||||
require.NoError(t, err, "output must be valid text exposition format")
|
||||
return families
|
||||
}
|
||||
|
||||
// TestMatchesClientGolang checks that every go_* family this package emits has
|
||||
// the same name, type and help text as client_golang's default Go collector. The
|
||||
// official collector is used as the oracle, so a difference shows up here rather
|
||||
// than in someone's dashboard.
|
||||
func TestMatchesClientGolang(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(collectors.NewGoCollector())
|
||||
mfs, err := reg.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
reference := make(map[string]*dto.MetricFamily, len(mfs))
|
||||
for _, mf := range mfs {
|
||||
reference[mf.GetName()] = mf
|
||||
}
|
||||
ours := parseFamilies(t, gather(t))
|
||||
|
||||
checked := 0
|
||||
for name, want := range reference {
|
||||
if !strings.HasPrefix(name, "go_") {
|
||||
continue
|
||||
}
|
||||
got, ok := ours[name]
|
||||
require.True(t, ok, "%s is missing", name)
|
||||
require.Equal(t, want.GetType(), got.GetType(), "%s type", name)
|
||||
require.Equal(t, want.GetHelp(), got.GetHelp(), "%s help", name)
|
||||
checked++
|
||||
}
|
||||
require.Greater(t, checked, 20, "comparison covered too few families")
|
||||
}
|
||||
|
||||
// TestNoBusinessMetrics pins what frpc must not expose: no frp_ business metrics,
|
||||
// none of the handler instrumentation client_golang adds, and no process_
|
||||
// families. frpc reports Go runtime metrics only, on every platform.
|
||||
func TestNoBusinessMetrics(t *testing.T) {
|
||||
body := gather(t)
|
||||
require.NotContains(t, body, "frp_")
|
||||
require.NotContains(t, body, "promhttp_")
|
||||
require.NotContains(t, body, "process_")
|
||||
}
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
reg.RegisterRuntime()
|
||||
rec := httptest.NewRecorder()
|
||||
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, ContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
families := parseFamilies(t, rec.Body.String())
|
||||
require.Contains(t, families, "go_goroutines")
|
||||
gc, ok := families["go_gc_duration_seconds"]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, dto.MetricType_SUMMARY, gc.GetType())
|
||||
require.Len(t, gc.GetMetric()[0].GetSummary().GetQuantile(), 5)
|
||||
}
|
||||
|
||||
func TestEmptyRegistry(t *testing.T) {
|
||||
var b strings.Builder
|
||||
require.NoError(t, NewRegistry().WritePrometheus(&b))
|
||||
require.Empty(t, b.String())
|
||||
}
|
||||
|
||||
func TestEscaping(t *testing.T) {
|
||||
var b strings.Builder
|
||||
require.NoError(t, encode(&b, []family{{
|
||||
name: "test_labels",
|
||||
help: "help with \\ and\nnewline",
|
||||
typ: typeGauge,
|
||||
samples: []sample{{labels: []label{{name: "v", value: "a\"b\\c\nd"}}, value: 1}},
|
||||
}}))
|
||||
require.Equal(t, "# HELP test_labels help with \\\\ and\\nnewline\n"+
|
||||
"# TYPE test_labels gauge\n"+
|
||||
"test_labels{v=\"a\\\"b\\\\c\\nd\"} 1\n", b.String())
|
||||
}
|
||||
@@ -47,6 +47,21 @@ var _ = ginkgo.Describe("[Feature: Metrics Server]", func() {
|
||||
}, 5*time.Second, 50*time.Millisecond).Should(gomega.BeTrue())
|
||||
}
|
||||
|
||||
// frpc serves Go runtime metrics only; no process, business or handler
|
||||
// instrumentation metrics may appear there, on any platform.
|
||||
expectRuntimeMetrics := func(port int) {
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", false)).
|
||||
Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", true)).Ensure(func(resp *request.Response) bool {
|
||||
body := string(resp.Content)
|
||||
return resp.Code == http.StatusOK &&
|
||||
strings.Contains(body, "go_goroutines") &&
|
||||
!strings.Contains(body, "process_") &&
|
||||
!strings.Contains(body, "frp_") &&
|
||||
!strings.Contains(body, "promhttp_")
|
||||
})
|
||||
}
|
||||
|
||||
ginkgo.It("serves anonymous health alongside both web servers", func() {
|
||||
serverMetrics, clientMetrics := f.AllocPort(), f.AllocPort()
|
||||
serverWeb, clientWeb := f.AllocPort(), f.AllocPort()
|
||||
@@ -102,7 +117,7 @@ webServer.password = "admin-secret"
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(port, "/healthz", false)).Ensure(framework.ExpectResponseCode(http.StatusOK))
|
||||
}
|
||||
expectBusinessMetrics(serverPort)
|
||||
framework.NewRequestExpect(f).Request(metricsRequest(clientPort, "/metrics", true)).Ensure(framework.ExpectResponseCode(http.StatusNotFound))
|
||||
expectRuntimeMetrics(clientPort)
|
||||
})
|
||||
|
||||
ginkgo.It("collects once when both metrics endpoints are enabled", func() {
|
||||
|
||||
Reference in New Issue
Block a user