feat: add transport-neutral request metrics
This commit is contained in:
@@ -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` 与客户端/服务端混合分配的解释。
|
||||
|
||||
11
README.md
11
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`:
|
||||
|
||||
106
metrics/metrics.go
Normal file
106
metrics/metrics.go
Normal 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
67
metrics/metrics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
4
omni.go
4
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user