feat: expose provider fetch metrics

This commit is contained in:
youfak 2026-08-02 13:55:23 +08:00
parent 38f4504aa8
commit 162fd4b7b8
11 changed files with 261 additions and 11 deletions

View File

@ -46,6 +46,11 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
- **Drain 指标**Controller 暴露 `proxy_pool_controller_drain_candidates_total`
`proxy_pool_controller_drains_started_total``reason` 仅有 `unhealthy`
`upstream_disabled`,不包含 Proxy、Worker、Upstream、会话或地址。
- **Provider 指标**Controller 暴露
`proxy_pool_controller_provider_fetch_results_total{class}`
`proxy_pool_controller_provider_valid_candidates_total`
`proxy_pool_controller_provider_new_proxies_total``class` 仅有 `valid`
`empty`、`duplicate_only`、`error`,不包含 Upstream、Proxy 或错误文本标签。
- **PostgreSQL 管理面**持久化配置版本、Upstream/Routing 管理状态、Admin
审计与 Outbox不保存 Proxy 明细或逐次提取记录。
- **Gateway 组件**HTTP 正向代理、HTTPS CONNECT、双向 Tunnel、重试、超时、

View File

@ -205,8 +205,8 @@ Distribution/Admin 服务构造、错误合并和资源关闭。生产 Provider
停机Admin disable 会取消 Runtimereload 在提交前预检并在发布后替换运行实例。
组合 fixture 已验证隔离 Redis namespace 下的选主、Provider HTTP 调用、模板解析
和活动池写入。Controller Metrics 独立入口现已提供 `/livez`、`/readyz` 与基础
Prometheus 运行时指标,三监听器隔离已通过测试;业务指标仍待
实现。双存储 bootstrap 已通过 PostgreSQL 18 + Redis 8.2 组合 fixture覆盖
Prometheus 运行时指标,三监听器隔离已通过测试;Checker、Gateway、Drain 与
Provider 业务指标已接入,提取、容量指标和密钥安全的结构化日志仍待实现。双存储 bootstrap 已通过 PostgreSQL 18 + Redis 8.2 组合 fixture覆盖
迁移、启动配置提交、Readiness、Admin Status 和 Metrics 探针。
WorkerControlPlane 现已接入 Controller 生命周期Register、ACK 和 Runtime
@ -351,7 +351,10 @@ Supervisor 也改为同时服从静态配置与管理态,消除两条启停消
`BASIC`、`EGRESS`、`TARGET` 级别及 `accepted`、`rejected` 结果。Gateway 还暴露
`proxy_pool_gateway_outcomes_total{stage,result}`
`proxy_pool_gateway_outcome_queue_dropped_total`其中阶段和结果均为固定枚举。Provider、
提取、容量指标与密钥安全的结构化日志仍待补齐。
拉取已暴露 `proxy_pool_controller_provider_fetch_results_total{class}`
`proxy_pool_controller_provider_valid_candidates_total`
`proxy_pool_controller_provider_new_proxies_total``class` 仅允许 `valid`、`empty`、
`duplicate_only`、`error`。Provider 指标已完成,提取、容量指标与密钥安全的结构化日志仍待补齐。
## Task 14: Documentation, Examples, and Diagrams

View File

@ -87,5 +87,5 @@
| OPS-001 | 配置校验后构建不可变快照并原子替换 | 8959-8999 | 100k 索引、版本/epoch 与并发 Apply/Acquire 测试 |
| OPS-002 | 优雅停机停止新请求/Fetch等待现有流量后超时关闭 | 8981-9000 | Provider Run 收敛与 `Handler.Shutdown` HTTP 排空、Hijacked CONNECT 超时关闭测试 |
| OPS-003 | PostgreSQL 只保存管理修订、Upstream/Routing 状态、Admin 审计与 Outbox | 当前会话 | ADR-006、`adminstate` 公用契约和六表 Schema 边界测试;真实 PostgreSQL 契约待完成 |
| OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | Controller Prometheus/探针模块已实现Checker Collector 暴露固定等级和结果Gateway Collector 暴露 `proxy_pool_gateway_outcomes_total{stage,result}``proxy_pool_gateway_outcome_queue_dropped_total`Drain Collector 暴露 `proxy_pool_controller_drain_candidates_total{reason}``proxy_pool_controller_drains_started_total{reason}`,其中 reason 固定为 unhealthy/upstream_disabled注册表测试锁定标签集。Provider、提取和容量业务指标待实现 |
| OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | Controller Prometheus/探针模块已实现Checker Collector 暴露固定等级和结果Gateway Collector 暴露 `proxy_pool_gateway_outcomes_total{stage,result}``proxy_pool_gateway_outcome_queue_dropped_total`Drain Collector 暴露 `proxy_pool_controller_drain_candidates_total{reason}``proxy_pool_controller_drains_started_total{reason}`,其中 reason 固定为 unhealthy/upstream_disabledProvider Collector 暴露 `proxy_pool_controller_provider_fetch_results_total{class}`、`proxy_pool_controller_provider_valid_candidates_total` 与 `proxy_pool_controller_provider_new_proxies_total`,其中 class 固定为 valid/empty/duplicate_only/error注册表测试锁定标签集。Provider 指标已完成,提取、容量指标和密钥安全的结构化日志仍待实现 |
| TEST-001 | 覆盖对话中列出的 11 个关键并发与故障场景 | 9030-9082 | 测试清单Redis 活动池由 Memory/Redis 公用契约覆盖,跨进程故障场景仍按清单推进 |

View File

@ -49,6 +49,13 @@ Extract 请求使用哪些 Upstream。
- Gateway、Distribution、Admin、Metrics 使用独立监听和认证/访问控制。
- 非回环监听且无认证、无 CIDR 保护时,严格模式必须拒绝启动。
## 可观测性边界
- Provider 拉取结果只按固定 `class` 聚合:`valid`、`empty`、`duplicate_only`、`error`。
指标分别记录拉取结果次数、合法候选数和新入池代理数,不按 Upstream、Proxy、IP、错误
文本或凭据拆分。
- Provider 指标已完成;提取、容量指标和密钥安全的结构化日志仍待实现。
## 集群与性能
- 用户补充:高峰可能达到 100,000 请求/秒。

View File

@ -237,6 +237,13 @@ func runWithWorkerFactory(
return fmt.Errorf("%w: build Drain metrics: %w", ErrStartup, drainCollectorErr)
}
drainMetrics = drainCollector
providerCollector, providerCollectorErr := platformMetrics.NewProviderCollector(prometheus.DefaultRegisterer)
if providerCollectorErr != nil {
return fmt.Errorf("%w: build Provider metrics: %w", ErrStartup, providerCollectorErr)
}
if registrar, ok := opened.providerResults.(provider.ResultObserverRegistrar); ok && !nilInterface(registrar) {
registrar.AddResultObserver(providerCollector)
}
handler, handlerErr := platformMetrics.NewHandler(platformMetrics.Dependencies{
Gatherer: prometheus.DefaultGatherer, Readiness: opened.metricsReadiness,
})

View File

@ -49,6 +49,18 @@ type ResultRecorder interface {
Record(Result)
}
// ResultObserver receives one validated Provider fetch result. Observers must
// not retain sensitive response data or block the fetch reconciliation loop.
type ResultObserver interface {
ObserveProviderResult(Result)
}
// ResultObserverRegistrar lets bootstrap attach low-cardinality observers to
// a shared result recorder without changing the Provider runtime contract.
type ResultObserverRegistrar interface {
AddResultObserver(ResultObserver)
}
type Ports struct {
Adapter ProviderAdapter
Parser Parser

View File

@ -3,6 +3,7 @@ package provider
import (
"errors"
"math"
"reflect"
"sync"
"proxy-pool/internal/domain/upstream"
@ -25,9 +26,10 @@ type StatsRetainer interface {
}
type StatsRecorder struct {
mu sync.Mutex
maximum int
byID map[string]Stats
mu sync.Mutex
maximum int
byID map[string]Stats
observers []ResultObserver
}
func NewStatsRecorder(maximum int) (*StatsRecorder, error) {
@ -38,14 +40,16 @@ func NewStatsRecorder(maximum int) (*StatsRecorder, error) {
}
func (recorder *StatsRecorder) Record(result Result) {
if recorder == nil || result.UpstreamID == "" {
if recorder == nil || result.UpstreamID == "" || !validResultClass(result.Class) {
return
}
recorder.mu.Lock()
defer recorder.mu.Unlock()
observers := append([]ResultObserver(nil), recorder.observers...)
stats, exists := recorder.byID[result.UpstreamID]
if !exists {
if len(recorder.byID) >= recorder.maximum {
recorder.mu.Unlock()
recorder.observe(observers, result)
return
}
stats.UpstreamID = result.UpstreamID
@ -61,10 +65,50 @@ func (recorder *StatsRecorder) Record(result Result) {
if stats.FetchErrorCount < math.MaxInt64 {
stats.FetchErrorCount++
}
default:
return
}
recorder.byID[result.UpstreamID] = stats
recorder.mu.Unlock()
recorder.observe(observers, result)
}
// AddResultObserver appends one process-local observer. Duplicate instances
// are ignored so repeated bootstrap assembly cannot double-count metrics.
func (recorder *StatsRecorder) AddResultObserver(observer ResultObserver) {
if recorder == nil || observer == nil {
return
}
recorder.mu.Lock()
defer recorder.mu.Unlock()
for _, existing := range recorder.observers {
if sameResultObserver(existing, observer) {
return
}
}
recorder.observers = append(recorder.observers, observer)
}
func (recorder *StatsRecorder) observe(observers []ResultObserver, result Result) {
for _, observer := range observers {
observer.ObserveProviderResult(result)
}
}
func validResultClass(class upstream.FetchClass) bool {
switch class {
case upstream.FetchValid, upstream.FetchEmpty, upstream.FetchDuplicateOnly, upstream.FetchError:
return true
default:
return false
}
}
func sameResultObserver(left, right ResultObserver) bool {
leftValue := reflect.ValueOf(left)
rightValue := reflect.ValueOf(right)
if !leftValue.IsValid() || !rightValue.IsValid() || leftValue.Type() != rightValue.Type() || !leftValue.Type().Comparable() {
return false
}
return leftValue.Interface() == rightValue.Interface()
}
func (recorder *StatsRecorder) ReadProviderStats(upstreamIDs []string) []Stats {
@ -101,3 +145,5 @@ func (recorder *StatsRecorder) RetainProviderStats(upstreamIDs []string) {
}
}
}
var _ ResultObserverRegistrar = (*StatsRecorder)(nil)

View File

@ -81,3 +81,30 @@ func TestStatsRecorderRetainsConfiguredProvidersAndReusesCapacity(t *testing.T)
t.Fatalf("stats after retention = %+v", stats)
}
}
func TestStatsRecorderObservesValidatedResultsOutsideBoundedStatusStorage(t *testing.T) {
recorder, err := NewStatsRecorder(1)
if err != nil {
t.Fatalf("NewStatsRecorder() = %v", err)
}
observer := &providerResultObserverStub{}
recorder.AddResultObserver(observer)
recorder.AddResultObserver(observer)
recorder.Record(Result{UpstreamID: "provider-a", Class: upstream.FetchEmpty})
recorder.Record(Result{UpstreamID: "provider-b", Class: upstream.FetchValid, ValidCount: 3, NewCount: 2})
recorder.Record(Result{UpstreamID: "provider-c", Class: "invalid"})
if len(observer.results) != 2 || observer.results[0].UpstreamID != "provider-a" || observer.results[1].UpstreamID != "provider-b" {
t.Fatalf("observed results = %+v", observer.results)
}
stats := recorder.ReadProviderStats([]string{"provider-a", "provider-b"})
if stats[0].ConsecutiveEmptyFetch != 1 || stats[1].ConsecutiveEmptyFetch != 0 || stats[1].FetchErrorCount != 0 {
t.Fatalf("bounded status stats = %+v", stats)
}
}
type providerResultObserverStub struct{ results []Result }
func (observer *providerResultObserverStub) ObserveProviderResult(result Result) {
observer.results = append(observer.results, result)
}

View File

@ -0,0 +1,68 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
controllerProvider "proxy-pool/internal/controller/provider"
"proxy-pool/internal/domain/upstream"
)
// ProviderCollector exposes bounded Provider fetch outcomes. It never uses
// upstream names, proxy addresses, response bodies or error strings as labels.
type ProviderCollector struct {
results *prometheus.CounterVec
candidates prometheus.Counter
inserted prometheus.Counter
}
var _ controllerProvider.ResultObserver = (*ProviderCollector)(nil)
func NewProviderCollector(registerer prometheus.Registerer) (*ProviderCollector, error) {
if registerer == nil {
return nil, ErrInvalidDependencies
}
results, err := registerCounterVec(registerer, prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "proxy_pool", Subsystem: "controller", Name: "provider_fetch_results_total",
Help: "Number of completed Provider fetch reconciliations by fixed result class.",
}, []string{"class"}))
if err != nil {
return nil, err
}
candidates, err := registerCounter(registerer, prometheus.NewCounter(prometheus.CounterOpts{
Namespace: "proxy_pool", Subsystem: "controller", Name: "provider_valid_candidates_total",
Help: "Number of valid Provider proxy candidates parsed before deduplication.",
}))
if err != nil {
return nil, err
}
inserted, err := registerCounter(registerer, prometheus.NewCounter(prometheus.CounterOpts{
Namespace: "proxy_pool", Subsystem: "controller", Name: "provider_new_proxies_total",
Help: "Number of Provider proxy candidates inserted into the activity pool.",
}))
if err != nil {
return nil, err
}
return &ProviderCollector{results: results, candidates: candidates, inserted: inserted}, nil
}
func (collector *ProviderCollector) ObserveProviderResult(result controllerProvider.Result) {
if collector == nil || collector.results == nil || !validProviderFetchClass(result.Class) {
return
}
collector.results.WithLabelValues(string(result.Class)).Inc()
if result.ValidCount > 0 && collector.candidates != nil {
collector.candidates.Add(float64(result.ValidCount))
}
if result.NewCount > 0 && collector.inserted != nil {
collector.inserted.Add(float64(result.NewCount))
}
}
func validProviderFetchClass(class upstream.FetchClass) bool {
switch class {
case upstream.FetchValid, upstream.FetchEmpty, upstream.FetchDuplicateOnly, upstream.FetchError:
return true
default:
return false
}
}

View File

@ -0,0 +1,69 @@
package metrics
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
controllerProvider "proxy-pool/internal/controller/provider"
"proxy-pool/internal/domain/upstream"
)
func TestProviderCollectorRecordsOnlyFixedResultClasses(t *testing.T) {
registry := prometheus.NewRegistry()
collector, err := NewProviderCollector(registry)
if err != nil {
t.Fatalf("NewProviderCollector() error = %v", err)
}
collector.ObserveProviderResult(controllerProvider.Result{Class: upstream.FetchValid, ValidCount: 5, NewCount: 2})
collector.ObserveProviderResult(controllerProvider.Result{Class: upstream.FetchDuplicateOnly, ValidCount: 3})
collector.ObserveProviderResult(controllerProvider.Result{Class: upstream.FetchEmpty})
collector.ObserveProviderResult(controllerProvider.Result{Class: upstream.FetchError})
collector.ObserveProviderResult(controllerProvider.Result{Class: "invalid", ValidCount: 9, NewCount: 9})
assertMetricValue(t, registry, "proxy_pool_controller_provider_fetch_results_total", map[string]string{"class": "valid"}, 1)
assertMetricValue(t, registry, "proxy_pool_controller_provider_fetch_results_total", map[string]string{"class": "duplicate_only"}, 1)
assertMetricValue(t, registry, "proxy_pool_controller_provider_fetch_results_total", map[string]string{"class": "empty"}, 1)
assertMetricValue(t, registry, "proxy_pool_controller_provider_fetch_results_total", map[string]string{"class": "error"}, 1)
assertMetricValue(t, registry, "proxy_pool_controller_provider_valid_candidates_total", nil, 8)
assertMetricValue(t, registry, "proxy_pool_controller_provider_new_proxies_total", nil, 2)
}
func TestNewProviderCollectorReusesRegisteredCollectors(t *testing.T) {
registry := prometheus.NewRegistry()
first, err := NewProviderCollector(registry)
if err != nil {
t.Fatalf("first NewProviderCollector() error = %v", err)
}
second, err := NewProviderCollector(registry)
if err != nil {
t.Fatalf("second NewProviderCollector() error = %v", err)
}
first.ObserveProviderResult(controllerProvider.Result{Class: upstream.FetchValid, ValidCount: 1, NewCount: 1})
second.ObserveProviderResult(controllerProvider.Result{Class: upstream.FetchValid, ValidCount: 2, NewCount: 2})
assertMetricValue(t, registry, "proxy_pool_controller_provider_fetch_results_total", map[string]string{"class": "valid"}, 2)
assertMetricValue(t, registry, "proxy_pool_controller_provider_valid_candidates_total", nil, 3)
assertMetricValue(t, registry, "proxy_pool_controller_provider_new_proxies_total", nil, 3)
}
func TestProviderCollectorObservesSharedStatsRecorder(t *testing.T) {
registry := prometheus.NewRegistry()
collector, err := NewProviderCollector(registry)
if err != nil {
t.Fatalf("NewProviderCollector() error = %v", err)
}
recorder, err := controllerProvider.NewStatsRecorder(1)
if err != nil {
t.Fatalf("NewStatsRecorder() error = %v", err)
}
recorder.AddResultObserver(collector)
recorder.Record(controllerProvider.Result{UpstreamID: "provider-a", Class: upstream.FetchValid, ValidCount: 2, NewCount: 1})
// The bounded Admin-status map has no room for provider-b, but metrics must
// retain the process-wide fetch observation.
recorder.Record(controllerProvider.Result{UpstreamID: "provider-b", Class: upstream.FetchEmpty})
assertMetricValue(t, registry, "proxy_pool_controller_provider_fetch_results_total", map[string]string{"class": "valid"}, 1)
assertMetricValue(t, registry, "proxy_pool_controller_provider_fetch_results_total", map[string]string{"class": "empty"}, 1)
assertMetricValue(t, registry, "proxy_pool_controller_provider_valid_candidates_total", nil, 2)
assertMetricValue(t, registry, "proxy_pool_controller_provider_new_proxies_total", nil, 1)
}

View File

@ -2,6 +2,12 @@
## 2026-08-02
- Provider 拉取可观测性已接入公用 `ResultObserver`Controller 暴露
`proxy_pool_controller_provider_fetch_results_total{class}`
`proxy_pool_controller_provider_valid_candidates_total`
`proxy_pool_controller_provider_new_proxies_total`。`class` 仅允许 `valid`、`empty`、
`duplicate_only`、`error`;指标不包含 Upstream、Proxy、IP、错误文本或凭据标签。
Provider 指标已完成,提取、容量指标和密钥安全的结构化日志仍待实现。
- 已实现管理态驱动的即时完整快照刷新:`SnapshotRefreshBroker` 以每个 Worker 流一格的
非阻塞通道广播连续变更自动合并。Admin 成功提交 Upstream 启停、Routing 切换和配置
发布时触发通知Worker 保留原有定时刷新作为失效保护。Routing 切换不按旧 Upstream