feat: add metrics support to mock server (#606)

* feat: add metrics support to mock server

* add unit tests

* fix the unit testing

---------

Co-authored-by: rick <LinuxSuRen@users.noreply.github.com>
This commit is contained in:
Rick 2025-02-08 09:28:39 +08:00 committed by GitHub
parent 664451e6ee
commit accdb0e4ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 189 additions and 25 deletions

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2023 API Testing Authors. Copyright 2023-2025 API Testing Authors.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -21,6 +21,7 @@ import (
"io" "io"
"os" "os"
"path" "path"
"strconv"
"testing" "testing"
"time" "time"
@ -36,7 +37,8 @@ func TestConvert(t *testing.T) {
c.SetOut(io.Discard) c.SetOut(io.Discard)
t.Run("normal", func(t *testing.T) { t.Run("normal", func(t *testing.T) {
tmpFile := path.Join(os.TempDir(), time.Now().String()) now := strconv.Itoa(int(time.Now().Unix()))
tmpFile := path.Join(os.TempDir(), now)
defer os.RemoveAll(tmpFile) defer os.RemoveAll(tmpFile)
c.SetArgs([]string{"convert", "-p=testdata/simple-suite.yaml", "--converter=jmeter", "--target", tmpFile}) c.SetArgs([]string{"convert", "-p=testdata/simple-suite.yaml", "--converter=jmeter", "--target", tmpFile})

View File

@ -17,6 +17,7 @@ limitations under the License.
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -37,7 +38,7 @@ func TestExtensionCmd(t *testing.T) {
t.Run("normal", func(t *testing.T) { t.Run("normal", func(t *testing.T) {
d := downloader.NewStoreDownloader() d := downloader.NewStoreDownloader()
server := mock.NewInMemoryServer(0) server := mock.NewInMemoryServer(context.Background(), 0)
err := server.Start(mock.NewLocalFileReader("../pkg/downloader/testdata/registry.yaml"), "/v2") err := server.Start(mock.NewLocalFileReader("../pkg/downloader/testdata/registry.yaml"), "/v2")
assert.NoError(t, err) assert.NoError(t, err)

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2024 API Testing Authors. Copyright 2024-2025 API Testing Authors.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -28,8 +28,9 @@ import (
) )
type mockOption struct { type mockOption struct {
port int port int
prefix string prefix string
metrics bool
} }
func createMockCmd() (c *cobra.Command) { func createMockCmd() (c *cobra.Command) {
@ -45,12 +46,16 @@ func createMockCmd() (c *cobra.Command) {
flags := c.Flags() flags := c.Flags()
flags.IntVarP(&opt.port, "port", "", 6060, "The mock server port") flags.IntVarP(&opt.port, "port", "", 6060, "The mock server port")
flags.StringVarP(&opt.prefix, "prefix", "", "/mock", "The mock server API prefix") flags.StringVarP(&opt.prefix, "prefix", "", "/mock", "The mock server API prefix")
flags.BoolVarP(&opt.metrics, "metrics", "m", true, "Enable request metrics collection")
return return
} }
func (o *mockOption) runE(c *cobra.Command, args []string) (err error) { func (o *mockOption) runE(c *cobra.Command, args []string) (err error) {
reader := mock.NewLocalFileReader(args[0]) reader := mock.NewLocalFileReader(args[0])
server := mock.NewInMemoryServer(o.port) server := mock.NewInMemoryServer(c.Context(), o.port)
if o.metrics {
server.EnableMetrics()
}
if err = server.Start(reader, o.prefix); err != nil { if err = server.Start(reader, o.prefix); err != nil {
return return
} }
@ -58,6 +63,9 @@ func (o *mockOption) runE(c *cobra.Command, args []string) (err error) {
clean := make(chan os.Signal, 1) clean := make(chan os.Signal, 1)
signal.Notify(clean, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) signal.Notify(clean, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
printLocalIPs(c, o.port) printLocalIPs(c, o.port)
if o.metrics {
c.Printf("Metrics available at http://localhost:%d%s/metrics\n", o.port, o.prefix)
}
select { select {
case <-c.Context().Done(): case <-c.Context().Done():

View File

@ -275,7 +275,7 @@ func (o *serverOption) runE(cmd *cobra.Command, args []string) (err error) {
mockWriter = mock.NewInMemoryReader("") mockWriter = mock.NewInMemoryReader("")
} }
dynamicMockServer := mock.NewInMemoryServer(0) dynamicMockServer := mock.NewInMemoryServer(cmd.Context(), 0)
mockServerController := server.NewMockServerController(mockWriter, dynamicMockServer, o.httpPort) mockServerController := server.NewMockServerController(mockWriter, dynamicMockServer, o.httpPort)
clean := make(chan os.Signal, 1) clean := make(chan os.Signal, 1)

View File

@ -210,7 +210,7 @@ func TestFrontEndHandlerWithLocation(t *testing.T) {
resp := newFakeResponseWriter() resp := newFakeResponseWriter()
opt.getAtestBinary(resp, req, map[string]string{}) opt.getAtestBinary(resp, req, map[string]string{})
assert.Equal(t, `failed to read "atest": open : no such file or directory`, resp.GetBody().String()) assert.Contains(t, resp.GetBody().String(), `failed to read "atest"`)
}) })
} }

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2024 API Testing Authors. Copyright 2024-2025 API Testing Authors.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@ limitations under the License.
package downloader package downloader
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -58,7 +59,7 @@ func TestDetectAuthURL(t *testing.T) {
} }
func TestDownload(t *testing.T) { func TestDownload(t *testing.T) {
server := mock.NewInMemoryServer(0) server := mock.NewInMemoryServer(context.Background(), 0)
err := server.Start(mock.NewLocalFileReader("testdata/registry.yaml"), "/v2") err := server.Start(mock.NewLocalFileReader("testdata/registry.yaml"), "/v2")
assert.NoError(t, err) assert.NoError(t, err)
defer func() { defer func() {

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2024 API Testing Authors. Copyright 2024-2025 API Testing Authors.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -53,15 +53,17 @@ type inMemoryServer struct {
ctx context.Context ctx context.Context
cancelFunc context.CancelFunc cancelFunc context.CancelFunc
reader Reader reader Reader
metrics RequestMetrics
} }
func NewInMemoryServer(port int) DynamicServer { func NewInMemoryServer(ctx context.Context, port int) DynamicServer {
ctx, cancel := context.WithCancel(context.TODO()) ctx, cancel := context.WithCancel(ctx)
return &inMemoryServer{ return &inMemoryServer{
port: port, port: port,
wg: sync.WaitGroup{}, wg: sync.WaitGroup{},
ctx: ctx, ctx: ctx,
cancelFunc: cancel, cancelFunc: cancel,
metrics: NewNoopMetrics(),
} }
} }
@ -72,6 +74,7 @@ func (s *inMemoryServer) SetupHandler(reader Reader, prefix string) (handler htt
s.mux = mux.NewRouter().PathPrefix(prefix).Subrouter() s.mux = mux.NewRouter().PathPrefix(prefix).Subrouter()
s.prefix = prefix s.prefix = prefix
handler = s.mux handler = s.mux
s.metrics.AddMetricsHandler(s.mux)
err = s.Load() err = s.Load()
return return
} }
@ -107,22 +110,31 @@ func (s *inMemoryServer) Load() (err error) {
memLogger.Info("start to proxy", "target", proxy.Target) memLogger.Info("start to proxy", "target", proxy.Target)
s.mux.HandleFunc(proxy.Path, func(w http.ResponseWriter, req *http.Request) { s.mux.HandleFunc(proxy.Path, func(w http.ResponseWriter, req *http.Request) {
api := fmt.Sprintf("%s/%s", proxy.Target, strings.TrimPrefix(req.URL.Path, s.prefix)) api := fmt.Sprintf("%s/%s", proxy.Target, strings.TrimPrefix(req.URL.Path, s.prefix))
api, err = render.Render("proxy api", api, s)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
memLogger.Error(err, "failed to render proxy api")
return
}
memLogger.Info("redirect to", "target", api) memLogger.Info("redirect to", "target", api)
targetReq, err := http.NewRequestWithContext(req.Context(), req.Method, api, req.Body) targetReq, err := http.NewRequestWithContext(req.Context(), req.Method, api, req.Body)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError)
memLogger.Error(err, "failed to create proxy request") memLogger.Error(err, "failed to create proxy request")
return return
} }
resp, err := http.DefaultClient.Do(targetReq) resp, err := http.DefaultClient.Do(targetReq)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError)
memLogger.Error(err, "failed to do proxy request") memLogger.Error(err, "failed to do proxy request")
return return
} }
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError)
memLogger.Error(err, "failed to read response body") memLogger.Error(err, "failed to read response body")
return return
} }
@ -148,10 +160,15 @@ func (s *inMemoryServer) Start(reader Reader, prefix string) (err error) {
return return
} }
func (s *inMemoryServer) EnableMetrics() {
s.metrics = NewInMemoryMetrics()
}
func (s *inMemoryServer) startObject(obj Object) { func (s *inMemoryServer) startObject(obj Object) {
// create a simple CRUD server // create a simple CRUD server
s.mux.HandleFunc("/"+obj.Name, func(w http.ResponseWriter, req *http.Request) { s.mux.HandleFunc("/"+obj.Name, func(w http.ResponseWriter, req *http.Request) {
fmt.Println("mock server received request", req.URL.Path) fmt.Println("mock server received request", req.URL.Path)
s.metrics.RecordRequest(req.URL.Path)
method := req.Method method := req.Method
w.Header().Set(util.ContentType, util.JSON) w.Header().Set(util.ContentType, util.JSON)
@ -210,6 +227,7 @@ func (s *inMemoryServer) startObject(obj Object) {
// handle a single object // handle a single object
s.mux.HandleFunc(fmt.Sprintf("/%s/{name}", obj.Name), func(w http.ResponseWriter, req *http.Request) { s.mux.HandleFunc(fmt.Sprintf("/%s/{name}", obj.Name), func(w http.ResponseWriter, req *http.Request) {
s.metrics.RecordRequest(req.URL.Path)
w.Header().Set(util.ContentType, util.JSON) w.Header().Set(util.ContentType, util.JSON)
objects := s.data[obj.Name] objects := s.data[obj.Name]
if objects != nil { if objects != nil {
@ -278,15 +296,17 @@ func (s *inMemoryServer) startItem(item Item) {
headerSlices = append(headerSlices, k, v) headerSlices = append(headerSlices, k, v)
} }
adHandler := &advanceHandler{item: &item} adHandler := &advanceHandler{item: &item, metrics: s.metrics}
s.mux.HandleFunc(item.Request.Path, adHandler.handle).Methods(strings.Split(method, ",")...).Headers(headerSlices...) s.mux.HandleFunc(item.Request.Path, adHandler.handle).Methods(strings.Split(method, ",")...).Headers(headerSlices...)
} }
type advanceHandler struct { type advanceHandler struct {
item *Item item *Item
metrics RequestMetrics
} }
func (h *advanceHandler) handle(w http.ResponseWriter, req *http.Request) { func (h *advanceHandler) handle(w http.ResponseWriter, req *http.Request) {
h.metrics.RecordRequest(req.URL.Path)
memLogger.Info("receiving mock request", "name", h.item.Name, "method", req.Method, "path", req.URL.Path, memLogger.Info("receiving mock request", "name", h.item.Name, "method", req.Method, "path", req.URL.Path,
"encoder", h.item.Response.Encoder) "encoder", h.item.Response.Encoder)

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2024 API Testing Authors. Copyright 2024-2025 API Testing Authors.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@ package mock
import ( import (
"bytes" "bytes"
"context"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@ -27,7 +28,8 @@ import (
) )
func TestInMemoryServer(t *testing.T) { func TestInMemoryServer(t *testing.T) {
server := NewInMemoryServer(0) server := NewInMemoryServer(context.Background(), 0)
server.EnableMetrics()
err := server.Start(NewLocalFileReader("testdata/api.yaml"), "/mock") err := server.Start(NewLocalFileReader("testdata/api.yaml"), "/mock")
assert.NoError(t, err) assert.NoError(t, err)
@ -165,13 +167,13 @@ func TestInMemoryServer(t *testing.T) {
}) })
t.Run("not found config file", func(t *testing.T) { t.Run("not found config file", func(t *testing.T) {
server := NewInMemoryServer(0) server := NewInMemoryServer(context.Background(), 0)
err := server.Start(NewLocalFileReader("fake"), "/") err := server.Start(NewLocalFileReader("fake"), "/")
assert.Error(t, err) assert.Error(t, err)
}) })
t.Run("invalid webhook", func(t *testing.T) { t.Run("invalid webhook", func(t *testing.T) {
server := NewInMemoryServer(0) server := NewInMemoryServer(context.Background(), 0)
err := server.Start(NewInMemoryReader(`webhooks: err := server.Start(NewInMemoryReader(`webhooks:
- timer: aa - timer: aa
name: fake`), "/") name: fake`), "/")
@ -179,14 +181,14 @@ func TestInMemoryServer(t *testing.T) {
}) })
t.Run("missing name or timer in webhook", func(t *testing.T) { t.Run("missing name or timer in webhook", func(t *testing.T) {
server := NewInMemoryServer(0) server := NewInMemoryServer(context.Background(), 0)
err := server.Start(NewInMemoryReader(`webhooks: err := server.Start(NewInMemoryReader(`webhooks:
- timer: 1s`), "/") - timer: 1s`), "/")
assert.Error(t, err) assert.Error(t, err)
}) })
t.Run("invalid webhook payload", func(t *testing.T) { t.Run("invalid webhook payload", func(t *testing.T) {
server := NewInMemoryServer(0) server := NewInMemoryServer(context.Background(), 0)
err := server.Start(NewInMemoryReader(`webhooks: err := server.Start(NewInMemoryReader(`webhooks:
- name: invalid - name: invalid
timer: 1ms timer: 1ms
@ -196,7 +198,7 @@ func TestInMemoryServer(t *testing.T) {
}) })
t.Run("invalid webhook api template", func(t *testing.T) { t.Run("invalid webhook api template", func(t *testing.T) {
server := NewInMemoryServer(0) server := NewInMemoryServer(context.Background(), 0)
err := server.Start(NewInMemoryReader(`webhooks: err := server.Start(NewInMemoryReader(`webhooks:
- name: invalid - name: invalid
timer: 1ms timer: 1ms
@ -205,4 +207,20 @@ func TestInMemoryServer(t *testing.T) {
path: "{{.fake"`), "/") path: "{{.fake"`), "/")
assert.NoError(t, err) assert.NoError(t, err)
}) })
t.Run("proxy", func(t *testing.T) {
resp, err = http.Get(api + "/v1/myProjects")
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Get(api + "/v1/invalid-template")
assert.NoError(t, err)
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
})
t.Run("metrics", func(t *testing.T) {
resp, err = http.Get(api + "/metrics")
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
} }

106
pkg/mock/metrics.go Normal file
View File

@ -0,0 +1,106 @@
/*
Copyright 2025 API Testing Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package mock
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/gorilla/mux"
)
var _ RequestMetrics = &NoopMetrics{}
// NoopMetrics implements RequestMetrics but does nothing
type NoopMetrics struct{}
// NewNoopMetrics creates a new NoopMetrics instance
func NewNoopMetrics() *NoopMetrics {
return &NoopMetrics{}
}
// RecordRequest implements RequestMetrics but does nothing
func (m *NoopMetrics) RecordRequest(path string) {}
// GetMetrics implements RequestMetrics but returns empty map
func (m *NoopMetrics) GetMetrics() MetricData {
return MetricData{}
}
// AddMetricsHandler implements RequestMetrics but does nothing
func (m *NoopMetrics) AddMetricsHandler(mux MetricsHandler) {}
type MetricsHandler interface {
HandleFunc(path string, f func(http.ResponseWriter, *http.Request)) *mux.Route
}
type MetricData struct {
FirstRequestTime time.Time
LastRequestTime time.Time
Requests map[string]int
}
// RequestMetrics represents an interface for collecting request metrics
type RequestMetrics interface {
RecordRequest(path string)
GetMetrics() MetricData
AddMetricsHandler(MetricsHandler)
}
var _ RequestMetrics = &InMemoryMetrics{}
// InMemoryMetrics implements RequestMetrics with in-memory storage
type InMemoryMetrics struct {
MetricData
mu sync.RWMutex
}
// NewInMemoryMetrics creates a new InMemoryMetrics instance
func NewInMemoryMetrics() *InMemoryMetrics {
return &InMemoryMetrics{
MetricData: MetricData{
Requests: make(map[string]int),
},
}
}
// RecordRequest records a request for the given path
func (m *InMemoryMetrics) RecordRequest(path string) {
m.mu.Lock()
defer m.mu.Unlock()
m.Requests[path]++
if m.FirstRequestTime.IsZero() {
m.FirstRequestTime = time.Now()
}
m.LastRequestTime = time.Now()
}
// GetMetrics returns a copy of the current metrics
func (m *InMemoryMetrics) GetMetrics() MetricData {
m.mu.RLock()
defer m.mu.RUnlock()
return m.MetricData
}
func (m *InMemoryMetrics) AddMetricsHandler(mux MetricsHandler) {
// Add metrics endpoint
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
metrics := m.GetMetrics()
_ = json.NewEncoder(w).Encode(metrics)
})
}

View File

@ -15,7 +15,9 @@ limitations under the License.
*/ */
package mock package mock
import "net/http" import (
"net/http"
)
type Loadable interface { type Loadable interface {
Load() error Load() error
@ -26,6 +28,7 @@ type DynamicServer interface {
SetupHandler(reader Reader, prefix string) (http.Handler, error) SetupHandler(reader Reader, prefix string) (http.Handler, error)
Stop() error Stop() error
GetPort() string GetPort() string
EnableMetrics()
Loadable Loadable
} }

View File

@ -49,6 +49,11 @@ items:
"status": "success" "status": "success"
}] }]
} }
proxies:
- path: /v1/myProjects
target: http://localhost:{{.GetPort}}
- path: /v1/invalid-template
target: http://localhost:{{.GetPort}
webhooks: webhooks:
- timer: 1ms - timer: 1ms
name: baidu name: baidu

View File

@ -1274,7 +1274,7 @@ func (s *mockServerController) Reload(ctx context.Context, in *MockConfig) (repl
} }
} }
server := mock.NewInMemoryServer(int(in.GetPort())) server := mock.NewInMemoryServer(ctx, int(in.GetPort()))
server.Start(s.mockWriter, in.Prefix) server.Start(s.mockWriter, in.Prefix)
s.loader = server s.loader = server
} }