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:
fatedier
2026-10-20 16:13:38 +08:00
committed by GitHub
parent b0bf32571a
commit 69ec4f0145
12 changed files with 1006 additions and 13 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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
+80
View File
@@ -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
}
+398
View File
@@ -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
}
+193
View File
@@ -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()
}
+163
View File
@@ -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)
}
+124
View File
@@ -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())
}
+16 -1
View File
@@ -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() {