diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d163f0..86dc777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。 - native 请求体改为流式读取,Content-Length 与 chunked body 不再在进入 Handler 前整体复制到内存;保留大小限制、读超时、trailer 校验和 Keep-Alive 排空。 - 统一标准库与 native 的真实 TCP benchmark,增加固定并发档位、响应状态与读写错误检查。 - 新增独立进程并发与长连接内存测量工具,记录成功吞吐、P95/P99、服务端分配、空闲连接堆内存和回收情况;修正并行 `ns/op` 与客户端/服务端混合分配的解释。 diff --git a/README.md b/README.md index 3237cf7..f3125e3 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ go-omni/ ├── health/ # 存活和就绪检查 ├── middleware/ # 请求 ID 等基础中间件 ├── logx/ # 业务层结构化日志 +├── metrics/ # 请求指标与状态统计 ├── config/ # 配置管理 ├── validate/ # 参数校验 ├── auth/ # 认证和权限接口 @@ -132,6 +133,16 @@ func (s *UserService) Create(ctx *httpx.Context) error { } ``` +需要基础运行指标时,可以注册 transport-neutral metrics middleware 和接口: + +```go +requestMetrics := omni.NewMetrics() +app.Use(requestMetrics.Middleware()) +app.GET("/metrics", requestMetrics.Handler()) +``` + +接口返回请求总数、错误数、当前处理中请求、累计耗时和 HTTP 状态分布。它是进程内快照,生产环境可由业务侧定时采集到监控系统。 + `WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。 应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`: diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 0000000..d68e7e2 --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,106 @@ +// Package metrics provides a small transport-neutral request metrics collector. +// It is intentionally in-memory and dependency-free; applications can expose +// Snapshot through any route or export it to their monitoring system. +package metrics + +import ( + "sync/atomic" + "time" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" +) + +// Snapshot is a point-in-time view of request metrics collected by Metrics. +type Snapshot struct { + Requests uint64 `json:"requests"` + Errors uint64 `json:"errors"` + InFlight int64 `json:"inFlight"` + TotalDurationMs float64 `json:"totalDurationMs"` + StatusCounts map[string]uint64 `json:"statusCounts"` +} + +// Metrics collects request counts, status counts, in-flight requests and +// cumulative handler time. A zero Metrics value is ready for use. +type Metrics struct { + requests atomic.Uint64 + errors atomic.Uint64 + inFlight atomic.Int64 + durationNs atomic.Int64 + statuses [600]atomic.Uint64 +} + +// New creates an empty metrics collector. +func New() *Metrics { return &Metrics{} } + +// Middleware records every request that reaches the middleware chain. Errors +// include returned handler errors and responses with a status of 400 or greater. +func (m *Metrics) Middleware() httpx.Middleware { + return func(next httpx.Handler) httpx.Handler { + return func(ctx *httpx.Context) error { + if m == nil { + return next(ctx) + } + m.inFlight.Add(1) + started := time.Now() + err := next(ctx) + status := ctx.Status() + if err != nil && !ctx.Responded() { + status = apierrors.From(err).Status + } + m.requests.Add(1) + if err != nil || status >= 400 { + m.errors.Add(1) + } + if status >= 0 && status < len(m.statuses) { + m.statuses[status].Add(1) + } + m.durationNs.Add(time.Since(started).Nanoseconds()) + m.inFlight.Add(-1) + return err + } + } +} + +// Handler exposes the current snapshot through the standard GoOmni response +// envelope. Register it explicitly, for example app.GET("/metrics", m.Handler()). +func (m *Metrics) Handler() httpx.Handler { + return func(ctx *httpx.Context) error { + return ctx.OK(m.Snapshot()) + } +} + +// Snapshot returns a consistent-enough point-in-time view for operational +// reporting. Counters may advance while the snapshot is being assembled. +func (m *Metrics) Snapshot() Snapshot { + if m == nil { + return Snapshot{StatusCounts: map[string]uint64{}} + } + statuses := make(map[string]uint64) + for status := 100; status < len(m.statuses); status++ { + if count := m.statuses[status].Load(); count > 0 { + statuses[itoa(status)] = count + } + } + return Snapshot{ + Requests: m.requests.Load(), + Errors: m.errors.Load(), + InFlight: m.inFlight.Load(), + TotalDurationMs: float64(m.durationNs.Load()) / float64(time.Millisecond), + StatusCounts: statuses, + } +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + var digits [3]byte + index := len(digits) + for value > 0 { + index-- + digits[index] = byte('0' + value%10) + value /= 10 + } + return string(digits[index:]) +} diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go new file mode 100644 index 0000000..02655a3 --- /dev/null +++ b/metrics/metrics_test.go @@ -0,0 +1,67 @@ +package metrics_test + +import ( + "encoding/json" + "errors" + "net/http/httptest" + "testing" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/metrics" +) + +func TestMiddlewareCollectsStatusesAndErrors(t *testing.T) { + collector := metrics.New() + request := httptest.NewRequest("GET", "/health", nil) + first := httptest.NewRecorder() + ctx := httpx.NewContext(request, first, nil) + if err := collector.Middleware()(func(c *httpx.Context) error { return c.OK("ok") })(ctx); err != nil { + t.Fatal(err) + } + + second := httptest.NewRecorder() + ctx = httpx.NewContext(request, second, nil) + handled := collector.Middleware()(func(*httpx.Context) error { + return apierrors.NotFound("MISSING", "missing") + }) + if err := handled(ctx); err == nil { + t.Fatal("expected handler error") + } + + snapshot := collector.Snapshot() + if snapshot.Requests != 2 || snapshot.Errors != 1 || snapshot.InFlight != 0 || snapshot.StatusCounts["200"] != 1 || snapshot.StatusCounts["404"] != 1 { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } +} + +func TestHandlerReturnsJSONSnapshot(t *testing.T) { + collector := metrics.New() + request := httptest.NewRequest("GET", "/metrics", nil) + recorder := httptest.NewRecorder() + ctx := httpx.NewContext(request, recorder, nil) + if err := collector.Handler()(ctx); err != nil { + t.Fatal(err) + } + var envelope struct { + Code string `json:"code"` + Data metrics.Snapshot `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Code != "OK" || envelope.Data.StatusCounts == nil { + t.Fatalf("unexpected metrics response: %s", recorder.Body) + } +} + +func TestMiddlewarePreservesApplicationErrors(t *testing.T) { + collector := metrics.New() + request := httptest.NewRequest("GET", "/", nil) + ctx := httpx.NewContext(request, httptest.NewRecorder(), nil) + want := errors.New("database unavailable") + got := collector.Middleware()(func(*httpx.Context) error { return want })(ctx) + if !errors.Is(got, want) { + t.Fatalf("middleware replaced error: %v", got) + } +} diff --git a/omni.go b/omni.go index 781a62e..a05844f 100644 --- a/omni.go +++ b/omni.go @@ -9,6 +9,7 @@ import ( apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/metrics" "git.yuntuo.hk/k2safe/go-omni/response" ) @@ -28,6 +29,7 @@ type Option = config.Option type Response = response.Response type LogConfig = logx.Config type Logger = logx.Logger +type Metrics = metrics.Metrics func New(options ...Option) *App { return app.New(options...) } @@ -49,6 +51,8 @@ func LoadConfig(prefix string) (Config, error) { return config.LoadFromEnv(prefi func SetupLogger(cfg LogConfig) (func() error, error) { return logx.Setup(cfg) } +func NewMetrics() *Metrics { return metrics.New() } + func NewError(status int, code, message string) *APIError { return apierrors.New(status, code, message) }