diff --git a/README.md b/README.md index 0b115b07..57116f20 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/Release.md b/Release.md index 53e7816c..ee1c07c6 100644 --- a/Release.md +++ b/Release.md @@ -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. diff --git a/client/metrics_server_test.go b/client/metrics_server_test.go index 22160558..2bd395f4 100644 --- a/client/metrics_server_test.go +++ b/client/metrics_server_test.go @@ -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_") } diff --git a/client/service.go b/client/service.go index c797be60..e7dcd8e0 100644 --- a/client/service.go +++ b/client/service.go @@ -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() diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index f4885cb7..6108e3aa 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -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 diff --git a/go.mod b/go.mod index 838ec8d1..228b3b21 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/pkg/metrics/text/registry.go b/pkg/metrics/text/registry.go new file mode 100644 index 00000000..cef2bc43 --- /dev/null +++ b/pkg/metrics/text/registry.go @@ -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 +} diff --git a/pkg/metrics/text/runtime.go b/pkg/metrics/text/runtime.go new file mode 100644 index 00000000..88ede04a --- /dev/null +++ b/pkg/metrics/text/runtime.go @@ -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 +} diff --git a/pkg/metrics/text/runtime_test.go b/pkg/metrics/text/runtime_test.go new file mode 100644 index 00000000..2484e041 --- /dev/null +++ b/pkg/metrics/text/runtime_test.go @@ -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() +} diff --git a/pkg/metrics/text/text.go b/pkg/metrics/text/text.go new file mode 100644 index 00000000..d4ab57db --- /dev/null +++ b/pkg/metrics/text/text.go @@ -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) +} diff --git a/pkg/metrics/text/text_test.go b/pkg/metrics/text/text_test.go new file mode 100644 index 00000000..3bc1bbb3 --- /dev/null +++ b/pkg/metrics/text/text_test.go @@ -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()) +} diff --git a/test/e2e/v1/features/metrics_server.go b/test/e2e/v1/features/metrics_server.go index 62619849..777532df 100644 --- a/test/e2e/v1/features/metrics_server.go +++ b/test/e2e/v1/features/metrics_server.go @@ -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() {