feat: add transport-neutral request metrics

This commit is contained in:
2026-09-22 10:00:02 +08:00
parent 5ab906098f
commit ef98d1dd8d
5 changed files with 189 additions and 0 deletions

View File

@@ -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` 与客户端/服务端混合分配的解释。

View File

@@ -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`:

106
metrics/metrics.go Normal file
View File

@@ -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:])
}

67
metrics/metrics_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)
}