diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 347518c..98a447d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main, dev] pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/README.md b/README.md index ae0a94d..d3c8512 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ curl -H "Accept: application/json" http://localhost:8080/monitor ## Fiber -`monitor` is built on `net/http`. Fiber is based on `fasthttp`, so the safest integration today is to create one monitor instance during startup and expose only the monitor endpoint through Fiber's official adaptor. +`monitor` is built on `net/http`. Fiber is based on `fasthttp`, so create one monitor instance during startup, expose only the monitor endpoint through Fiber's official adaptor, and record business requests with Fiber-native middleware. > Important: do not call `monitor.New` or `monitor.NewMonitor` inside `adaptor.HTTPMiddleware`. > Fiber executes that middleware factory for every request, while each monitor instance starts one background collector goroutine. Creating a monitor instance per request will leak collector goroutines. @@ -85,9 +85,10 @@ package main import ( "net/http" + "time" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/gofurry/monitor" ) @@ -99,9 +100,29 @@ func main() { }) defer m.Stop() + app.Use(func(c fiber.Ctx) error { + if c.Path() == "/monitor" { + return c.Next() + } + + started := time.Now() + m.RequestStarted() + err := c.Next() + + status := c.Response().StatusCode() + if err != nil { + status = fiber.StatusInternalServerError + if fiberErr, ok := err.(*fiber.Error); ok { + status = fiberErr.Code + } + } + m.RequestFinished(status, time.Since(started)) + return err + }) + app.All("/monitor", adaptor.HTTPHandler(m)) - app.Get("/", func(c *fiber.Ctx) error { + app.Get("/", func(c fiber.Ctx) error { return c.SendString("hello") }) @@ -111,7 +132,114 @@ func main() { Open `http://localhost:8080/monitor`. -This Fiber example safely serves the monitor page and JSON snapshot, but it does not wrap all Fiber routes. Therefore `http.total_requests` only reflects requests handled by this monitor handler. If you need full Fiber business request accounting, use a native Fiber adapter instead of wrapping `monitor.New` with `adaptor.HTTPMiddleware`. +This Fiber example safely serves the monitor page and JSON snapshot, while `http.total_requests`, in-flight requests, status code classes, and latency are recorded from the native Fiber middleware. + +## Gin + +Gin runs on `net/http`, but you can still use the framework-neutral request lifecycle methods when you want monitor to stay outside Gin's handler chain. + +```go +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/gofurry/monitor" +) + +func main() { + r := gin.New() + r.Use(gin.Recovery()) + + m := monitor.NewMonitor(http.NotFoundHandler(), monitor.Config{ + Path: "/monitor", + }) + defer m.Stop() + + r.Use(func(c *gin.Context) { + if c.Request.URL.Path == "/monitor" { + c.Next() + return + } + + started := time.Now() + m.RequestStarted() + c.Next() + + status := c.Writer.Status() + if status == 0 { + status = http.StatusOK + } + m.RequestFinished(status, time.Since(started)) + }) + + r.GET("/monitor", gin.WrapH(m)) + r.GET("/", func(c *gin.Context) { + c.String(http.StatusOK, "hello") + }) + + _ = r.Run(":8080") +} +``` + +## Echo + +Echo can also record requests with the same monitor lifecycle methods. + +```go +package main + +import ( + "net/http" + "time" + + "github.com/gofurry/monitor" + "github.com/labstack/echo/v4" +) + +func main() { + e := echo.New() + + m := monitor.NewMonitor(http.NotFoundHandler(), monitor.Config{ + Path: "/monitor", + }) + defer m.Stop() + + e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if c.Request().URL.Path == "/monitor" { + return next(c) + } + + started := time.Now() + m.RequestStarted() + err := next(c) + + status := c.Response().Status + if err != nil { + status = http.StatusInternalServerError + if echoErr, ok := err.(*echo.HTTPError); ok { + status = echoErr.Code + } + } + if status == 0 { + status = http.StatusOK + } + m.RequestFinished(status, time.Since(started)) + return err + } + }) + + e.GET("/monitor", echo.WrapHandler(m)) + e.GET("/", func(c echo.Context) error { + return c.String(http.StatusOK, "hello") + }) + + _ = e.Start(":8080") +} +``` ## Configuration diff --git a/doc.go b/doc.go index b052dbe..b9a5a38 100644 --- a/doc.go +++ b/doc.go @@ -5,6 +5,9 @@ // exposes one monitor path, and serves metrics from a race-safe background // snapshot. Requests to the monitor path are excluded from the HTTP request // count so page refreshes and JSON polling do not inflate business traffic. +// Frameworks that do not run on net/http can expose the monitor endpoint with +// their own adaptor and record requests through RequestStarted, +// RequestFinished, or ObserveRequest. // // Basic usage: // diff --git a/docs/zh/README.md b/docs/zh/README.md index d75d12b..e57cd6f 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -72,7 +72,7 @@ curl -H "Accept: application/json" http://localhost:8080/monitor ## Fiber -`monitor` 基于 `net/http`。Fiber 基于 `fasthttp`,因此当前最安全的接入方式是在服务启动阶段只创建一个 monitor 实例,然后通过 Fiber 官方 adaptor 只暴露监控端点。 +`monitor` 基于 `net/http`。Fiber 基于 `fasthttp`,因此推荐在服务启动阶段只创建一个 monitor 实例,通过 Fiber 官方 adaptor 只暴露监控端点,再用 Fiber 原生中间件记录业务请求。 > 重要:不要在 `adaptor.HTTPMiddleware` 内部调用 `monitor.New` 或 `monitor.NewMonitor`。 > Fiber 会在每个请求里执行这个中间件工厂函数,而每个 monitor 实例都会启动一个后台采集 goroutine。如果每个请求都创建 monitor 实例,就会泄漏采集 goroutine。 @@ -82,9 +82,10 @@ package main import ( "net/http" + "time" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/gofurry/monitor" ) @@ -96,9 +97,29 @@ func main() { }) defer m.Stop() + app.Use(func(c fiber.Ctx) error { + if c.Path() == "/monitor" { + return c.Next() + } + + started := time.Now() + m.RequestStarted() + err := c.Next() + + status := c.Response().StatusCode() + if err != nil { + status = fiber.StatusInternalServerError + if fiberErr, ok := err.(*fiber.Error); ok { + status = fiberErr.Code + } + } + m.RequestFinished(status, time.Since(started)) + return err + }) + app.All("/monitor", adaptor.HTTPHandler(m)) - app.Get("/", func(c *fiber.Ctx) error { + app.Get("/", func(c fiber.Ctx) error { return c.SendString("hello") }) @@ -108,7 +129,114 @@ func main() { 打开 `http://localhost:8080/monitor`。 -这个 Fiber 示例可以安全地提供监控页面和 JSON 快照,但它不会包裹所有 Fiber 路由。因此 `http.total_requests` 只会反映这个 monitor handler 处理到的请求。如果你需要完整统计 Fiber 业务请求,请使用原生 Fiber adapter,而不是用 `adaptor.HTTPMiddleware` 包装 `monitor.New`。 +这个 Fiber 示例可以安全地提供监控页面和 JSON 快照,同时通过原生 Fiber 中间件记录 `http.total_requests`、处理中请求、状态码分类和请求延迟。 + +## Gin + +Gin 运行在 `net/http` 之上,但如果你希望 monitor 不直接包裹 Gin handler,也可以使用框架无关的请求生命周期方法。 + +```go +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/gofurry/monitor" +) + +func main() { + r := gin.New() + r.Use(gin.Recovery()) + + m := monitor.NewMonitor(http.NotFoundHandler(), monitor.Config{ + Path: "/monitor", + }) + defer m.Stop() + + r.Use(func(c *gin.Context) { + if c.Request.URL.Path == "/monitor" { + c.Next() + return + } + + started := time.Now() + m.RequestStarted() + c.Next() + + status := c.Writer.Status() + if status == 0 { + status = http.StatusOK + } + m.RequestFinished(status, time.Since(started)) + }) + + r.GET("/monitor", gin.WrapH(m)) + r.GET("/", func(c *gin.Context) { + c.String(http.StatusOK, "hello") + }) + + _ = r.Run(":8080") +} +``` + +## Echo + +Echo 也可以用同一组 monitor 生命周期方法记录请求。 + +```go +package main + +import ( + "net/http" + "time" + + "github.com/gofurry/monitor" + "github.com/labstack/echo/v4" +) + +func main() { + e := echo.New() + + m := monitor.NewMonitor(http.NotFoundHandler(), monitor.Config{ + Path: "/monitor", + }) + defer m.Stop() + + e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if c.Request().URL.Path == "/monitor" { + return next(c) + } + + started := time.Now() + m.RequestStarted() + err := next(c) + + status := c.Response().Status + if err != nil { + status = http.StatusInternalServerError + if echoErr, ok := err.(*echo.HTTPError); ok { + status = echoErr.Code + } + } + if status == 0 { + status = http.StatusOK + } + m.RequestFinished(status, time.Since(started)) + return err + } + }) + + e.GET("/monitor", echo.WrapHandler(m)) + e.GET("/", func(c echo.Context) error { + return c.String(http.StatusOK, "hello") + }) + + _ = e.Start(":8080") +} +``` ## 配置 diff --git a/monitor.go b/monitor.go index 1a0dbb9..4d63f02 100644 --- a/monitor.go +++ b/monitor.go @@ -103,6 +103,43 @@ func (m *Monitor) Current() Stats { return stats } +// RequestStarted records that a business request has entered a framework +// middleware or handler chain. +// +// Use it with RequestFinished when integrating monitor with frameworks that do +// not use net/http, such as Fiber, Gin, or Echo. Each call must be paired with +// exactly one RequestFinished call. +func (m *Monitor) RequestStarted() { + if m == nil { + return + } + m.requests.Add(1) + m.inFlight.Add(1) +} + +// RequestFinished records the status code and duration for a business request +// that was previously recorded with RequestStarted. +func (m *Monitor) RequestFinished(status int, duration time.Duration) { + if m == nil { + return + } + m.inFlight.Add(^uint64(0)) + m.recordBusinessRequest(status, duration) +} + +// ObserveRequest records one completed business request without changing the +// in-flight request counter. +// +// It is useful for framework adapters or tests that only observe completed +// requests and do not need in-flight tracking. +func (m *Monitor) ObserveRequest(status int, duration time.Duration) { + if m == nil { + return + } + m.requests.Add(1) + m.recordBusinessRequest(status, duration) +} + // Stop stops the background collector. It is safe to call Stop more than once. func (m *Monitor) Stop() { m.stopOnce.Do(func() { @@ -147,13 +184,11 @@ func (m *Monitor) ignoreRequest(r *http.Request) bool { } func (m *Monitor) serveBusiness(w http.ResponseWriter, r *http.Request) { - m.requests.Add(1) - m.inFlight.Add(1) + m.RequestStarted() started := time.Now() rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} defer func() { - m.inFlight.Add(^uint64(0)) - m.recordBusinessRequest(rw.status, time.Since(started)) + m.RequestFinished(rw.status, time.Since(started)) }() m.next.ServeHTTP(rw, r) diff --git a/monitor_benchmark_test.go b/monitor_benchmark_test.go index f5b56ce..685828d 100644 --- a/monitor_benchmark_test.go +++ b/monitor_benchmark_test.go @@ -138,14 +138,26 @@ func BenchmarkMonitorIgnoredRequest(b *testing.B) { benchmarkTotalRequests = calls } -func BenchmarkMonitorRecordBusinessRequest(b *testing.B) { +func BenchmarkMonitorObserveRequest(b *testing.B) { m := NewMonitor(http.NotFoundHandler(), Config{Refresh: time.Hour}) defer m.Stop() b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - m.recordBusinessRequest(http.StatusNoContent, time.Microsecond) + m.ObserveRequest(http.StatusNoContent, time.Microsecond) + } +} + +func BenchmarkMonitorManualRequestLifecycle(b *testing.B) { + m := NewMonitor(http.NotFoundHandler(), Config{Refresh: time.Hour}) + defer m.Stop() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.RequestStarted() + m.RequestFinished(http.StatusNoContent, time.Microsecond) } } diff --git a/monitor_test.go b/monitor_test.go index 795017d..b1a5911 100644 --- a/monitor_test.go +++ b/monitor_test.go @@ -186,6 +186,61 @@ func TestMonitorCollectsHTTPStatusLatencyAndInFlight(t *testing.T) { } } +func TestMonitorManualRequestLifecycle(t *testing.T) { + m := NewMonitor(http.NotFoundHandler(), Config{Refresh: time.Hour}) + defer m.Stop() + + m.RequestStarted() + m.collectOnce() + + if got := m.Current().HTTP.TotalRequests; got != 1 { + t.Fatalf("total requests after start = %d, want 1", got) + } + if got := m.Current().HTTP.InFlightRequests; got != 1 { + t.Fatalf("in-flight requests after start = %d, want 1", got) + } + + m.RequestFinished(http.StatusCreated, time.Millisecond) + m.collectOnce() + + httpStats := m.Current().HTTP + if got := httpStats.InFlightRequests; got != 0 { + t.Fatalf("in-flight requests after finish = %d, want 0", got) + } + if got := httpStats.StatusCodes.Status2xx; got != 1 { + t.Fatalf("2xx responses = %d, want 1", got) + } + if httpStats.Latency.LastNS == 0 { + t.Fatalf("last latency = %d, want > 0", httpStats.Latency.LastNS) + } +} + +func TestMonitorObserveRequestRecordsCompletedRequest(t *testing.T) { + m := NewMonitor(http.NotFoundHandler(), Config{Refresh: time.Hour}) + defer m.Stop() + + m.ObserveRequest(http.StatusNotFound, time.Millisecond) + m.ObserveRequest(http.StatusInternalServerError, 0) + m.collectOnce() + + httpStats := m.Current().HTTP + if got := httpStats.TotalRequests; got != 2 { + t.Fatalf("total requests = %d, want 2", got) + } + if got := httpStats.InFlightRequests; got != 0 { + t.Fatalf("in-flight requests = %d, want 0", got) + } + if got := httpStats.StatusCodes.Status4xx; got != 1 { + t.Fatalf("4xx responses = %d, want 1", got) + } + if got := httpStats.StatusCodes.Status5xx; got != 1 { + t.Fatalf("5xx responses = %d, want 1", got) + } + if httpStats.Latency.LastNS != uint64(time.Nanosecond) { + t.Fatalf("last latency = %d, want %d", httpStats.Latency.LastNS, time.Nanosecond) + } +} + func TestMonitorRecordsMinimumNanosecondLatency(t *testing.T) { m := NewMonitor(http.NotFoundHandler(), Config{Refresh: time.Hour}) defer m.Stop()