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");
you may not use this file except in compliance with the License.
@ -21,6 +21,7 @@ import (
"io"
"os"
"path"
"strconv"
"testing"
"time"
@ -36,7 +37,8 @@ func TestConvert(t *testing.T) {
c.SetOut(io.Discard)
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)
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
import (
"context"
"fmt"
"io"
"os"
@ -37,7 +38,7 @@ func TestExtensionCmd(t *testing.T) {
t.Run("normal", func(t *testing.T) {
d := downloader.NewStoreDownloader()
server := mock.NewInMemoryServer(0)
server := mock.NewInMemoryServer(context.Background(), 0)
err := server.Start(mock.NewLocalFileReader("../pkg/downloader/testdata/registry.yaml"), "/v2")
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");
you may not use this file except in compliance with the License.
@ -28,8 +28,9 @@ import (
)
type mockOption struct {
port int
prefix string
port int
prefix string
metrics bool
}
func createMockCmd() (c *cobra.Command) {
@ -45,12 +46,16 @@ func createMockCmd() (c *cobra.Command) {
flags := c.Flags()
flags.IntVarP(&opt.port, "port", "", 6060, "The mock server port")
flags.StringVarP(&opt.prefix, "prefix", "", "/mock", "The mock server API prefix")
flags.BoolVarP(&opt.metrics, "metrics", "m", true, "Enable request metrics collection")
return
}
func (o *mockOption) runE(c *cobra.Command, args []string) (err error) {
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 {
return
}
@ -58,6 +63,9 @@ func (o *mockOption) runE(c *cobra.Command, args []string) (err error) {
clean := make(chan os.Signal, 1)
signal.Notify(clean, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
printLocalIPs(c, o.port)
if o.metrics {
c.Printf("Metrics available at http://localhost:%d%s/metrics\n", o.port, o.prefix)
}
select {
case <-c.Context().Done():

View File

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

View File

@ -210,7 +210,7 @@ func TestFrontEndHandlerWithLocation(t *testing.T) {
resp := newFakeResponseWriter()
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");
you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@ limitations under the License.
package downloader
import (
"context"
"fmt"
"io"
"os"
@ -58,7 +59,7 @@ func TestDetectAuthURL(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")
assert.NoError(t, err)
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");
you may not use this file except in compliance with the License.
@ -53,15 +53,17 @@ type inMemoryServer struct {
ctx context.Context
cancelFunc context.CancelFunc
reader Reader
metrics RequestMetrics
}
func NewInMemoryServer(port int) DynamicServer {
ctx, cancel := context.WithCancel(context.TODO())
func NewInMemoryServer(ctx context.Context, port int) DynamicServer {
ctx, cancel := context.WithCancel(ctx)
return &inMemoryServer{
port: port,
wg: sync.WaitGroup{},
ctx: ctx,
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.prefix = prefix
handler = s.mux
s.metrics.AddMetricsHandler(s.mux)
err = s.Load()
return
}
@ -107,22 +110,31 @@ func (s *inMemoryServer) Load() (err error) {
memLogger.Info("start to proxy", "target", proxy.Target)
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, 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)
targetReq, err := http.NewRequestWithContext(req.Context(), req.Method, api, req.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
memLogger.Error(err, "failed to create proxy request")
return
}
resp, err := http.DefaultClient.Do(targetReq)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
memLogger.Error(err, "failed to do proxy request")
return
}
data, err := io.ReadAll(resp.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
memLogger.Error(err, "failed to read response body")
return
}
@ -148,10 +160,15 @@ func (s *inMemoryServer) Start(reader Reader, prefix string) (err error) {
return
}
func (s *inMemoryServer) EnableMetrics() {
s.metrics = NewInMemoryMetrics()
}
func (s *inMemoryServer) startObject(obj Object) {
// create a simple CRUD server
s.mux.HandleFunc("/"+obj.Name, func(w http.ResponseWriter, req *http.Request) {
fmt.Println("mock server received request", req.URL.Path)
s.metrics.RecordRequest(req.URL.Path)
method := req.Method
w.Header().Set(util.ContentType, util.JSON)
@ -210,6 +227,7 @@ func (s *inMemoryServer) startObject(obj Object) {
// handle a single object
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)
objects := s.data[obj.Name]
if objects != nil {
@ -278,15 +296,17 @@ func (s *inMemoryServer) startItem(item Item) {
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...)
}
type advanceHandler struct {
item *Item
item *Item
metrics RequestMetrics
}
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,
"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");
you may not use this file except in compliance with the License.
@ -17,6 +17,7 @@ package mock
import (
"bytes"
"context"
"io"
"net/http"
"strings"
@ -27,7 +28,8 @@ import (
)
func TestInMemoryServer(t *testing.T) {
server := NewInMemoryServer(0)
server := NewInMemoryServer(context.Background(), 0)
server.EnableMetrics()
err := server.Start(NewLocalFileReader("testdata/api.yaml"), "/mock")
assert.NoError(t, err)
@ -165,13 +167,13 @@ func TestInMemoryServer(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"), "/")
assert.Error(t, err)
})
t.Run("invalid webhook", func(t *testing.T) {
server := NewInMemoryServer(0)
server := NewInMemoryServer(context.Background(), 0)
err := server.Start(NewInMemoryReader(`webhooks:
- timer: aa
name: fake`), "/")
@ -179,14 +181,14 @@ func TestInMemoryServer(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:
- timer: 1s`), "/")
assert.Error(t, err)
})
t.Run("invalid webhook payload", func(t *testing.T) {
server := NewInMemoryServer(0)
server := NewInMemoryServer(context.Background(), 0)
err := server.Start(NewInMemoryReader(`webhooks:
- name: invalid
timer: 1ms
@ -196,7 +198,7 @@ func TestInMemoryServer(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:
- name: invalid
timer: 1ms
@ -205,4 +207,20 @@ func TestInMemoryServer(t *testing.T) {
path: "{{.fake"`), "/")
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
import "net/http"
import (
"net/http"
)
type Loadable interface {
Load() error
@ -26,6 +28,7 @@ type DynamicServer interface {
SetupHandler(reader Reader, prefix string) (http.Handler, error)
Stop() error
GetPort() string
EnableMetrics()
Loadable
}

View File

@ -49,6 +49,11 @@ items:
"status": "success"
}]
}
proxies:
- path: /v1/myProjects
target: http://localhost:{{.GetPort}}
- path: /v1/invalid-template
target: http://localhost:{{.GetPort}
webhooks:
- timer: 1ms
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)
s.loader = server
}