-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathclistats.go
262 lines (225 loc) · 7.11 KB
/
clistats.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
package clistats
import (
"context"
"fmt"
"io"
"net/http"
"sync/atomic"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/projectdiscovery/freeport"
errorutil "github.com/projectdiscovery/utils/errors"
)
// StatisticsClient is an interface implemented by a statistics client.
//
// A unique ID is to be provided along with a description for the field to be
// displayed as output.
//
// Multiple types of statistics are provided like Counters as well as static
// fields which display static information only.
//
// A metric cannot be added once the client has been started. An
// error will be returned if the metric cannot be added. Already existing fields
// of same names are overwritten.
type StatisticsClient interface {
// Start starts the event loop of the stats client.
Start() error
// Stop stops the event loop of the stats client
Stop() error
// AddCounter adds a uint64 counter field to the statistics client.
//
// A counter is used to track an increasing quantity, like requests,
// errors etc.
AddCounter(id string, value uint64)
// GetCounter returns the current value of a counter.
GetCounter(id string) (uint64, bool)
// IncrementCounter increments the value of a counter by a count.
IncrementCounter(id string, count int)
// AddStatic adds a static information field to the statistics.
//
// The value for these metrics will remain constant throughout the
// lifecycle of the statistics client. All the values will be
// converted into string and displayed as such.
AddStatic(id string, value interface{})
// GetStatic returns the original value for a static field.
GetStatic(id string) (interface{}, bool)
// AddDynamic adds a dynamic field to display whose value
// is retrieved by running a callback function.
//
// The callback function performs some actions and returns the value
// to display. Generally this is used for calculating requests per
// seconds, elapsed time, etc.
AddDynamic(id string, Callback DynamicCallback)
// GetDynamic returns the dynamic field callback for data retrieval.
GetDynamic(id string) (DynamicCallback, bool)
//GetStatResponse returns '/metrics' response for a given interval
GetStatResponse(interval time.Duration, callback func(string, error) error)
}
// DynamicCallback is called during statistics calculation for a dynamic
// field.
//
// The value returned from this callback is displayed as the current value
// of a dynamic field. This can be utilised to calculate things like elapsed
// time, requests per seconds, etc.
type DynamicCallback func(client StatisticsClient) interface{}
// Statistics is a client for showing statistics on the stdout.
type Statistics struct {
Options *Options
ctx context.Context
cancel context.CancelFunc
// counters is a list of counters for the client. These can only
// be accessed concurrently via atomic operations and once the main
// event loop has started must not be modified.
counters map[string]*atomic.Uint64
// static contains a list of static counters for the client.
static map[string]interface{}
// dynamic contains a list of dynamic metrics for the client.
dynamic map[string]DynamicCallback
httpServer *http.Server
}
var _ StatisticsClient = (*Statistics)(nil)
// New creates a new statistics client for cli stats printing with default options
func New() (*Statistics, error) {
return NewWithOptions(context.Background(), &DefaultOptions)
}
// NewWithOptions creates a new client with custom options
func NewWithOptions(ctx context.Context, options *Options) (*Statistics, error) {
ctx, cancel := context.WithCancel(ctx)
statistics := &Statistics{
Options: options,
ctx: ctx,
cancel: cancel,
counters: make(map[string]*atomic.Uint64),
static: make(map[string]interface{}),
dynamic: make(map[string]DynamicCallback),
}
return statistics, nil
}
func (s *Statistics) metricsHandler(w http.ResponseWriter, req *http.Request) {
items := make(map[string]interface{})
for k, v := range s.counters {
items[k] = v.Load()
}
for k, v := range s.static {
items[k] = v
}
for k, v := range s.dynamic {
items[k] = v(s)
}
// Common fields
requests, hasRequests := s.GetCounter("requests")
startedAt, hasStartedAt := s.GetStatic("startedAt")
total, hasTotal := s.GetCounter("total")
var (
duration time.Duration
hasDuration bool
)
// duration
if hasStartedAt {
if stAt, ok := startedAt.(time.Time); ok {
duration = time.Since(stAt)
items["duration"] = FmtDuration(duration)
hasDuration = true
}
}
// rps
if hasRequests && hasDuration {
items["rps"] = String(uint64(float64(requests) / duration.Seconds()))
}
// percent
if hasRequests && hasTotal {
percentData := (float64(requests) * float64(100)) / float64(total)
percent := String(uint64(percentData))
items["percent"] = percent
}
data, err := jsoniter.Marshal(items)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":"%s"}`, err)))
return
}
_, _ = w.Write(data)
}
// Start starts the event loop of the stats client.
func (s *Statistics) Start() error {
if s.httpServer != nil {
return errorutil.New("server already started")
}
if s.Options.Web {
mux := http.NewServeMux()
mux.HandleFunc("/metrics", s.metricsHandler)
// check if the default port is available
port, err := freeport.GetPort(freeport.TCP, "127.0.0.1", s.Options.ListenPort)
if err != nil {
// otherwise picks a random one and update the options
port, err = freeport.GetFreeTCPPort("127.0.0.1")
if err != nil {
return err
}
s.Options.ListenPort = port.Port
}
s.httpServer = &http.Server{
Addr: fmt.Sprintf("%s:%d", port.Address, port.Port),
Handler: mux,
}
errChan := make(chan error, 1)
var done atomic.Bool
go func() {
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && !done.Load() {
errChan <- err
}
}()
// catch initial fatal errors
select {
case err := <-errChan:
return err
case <-time.After(250 * time.Millisecond):
done.Store(true)
close(errChan)
}
}
return nil
}
// Stop stops the event loop of the stats client
func (s *Statistics) Stop() error {
defer s.cancel()
if s.httpServer != nil {
if err := s.httpServer.Shutdown(s.ctx); err != nil {
return err
}
}
s.httpServer = nil
return nil
}
// GetStatResponse returns '/metrics' response for a given interval
func (s *Statistics) GetStatResponse(interval time.Duration, callback func(string, error) error) {
metricCallback := func(url string) (string, error) {
response, err := http.Get(url)
if err != nil {
return "", errorutil.New("Error getting /metrics response: %v", err)
}
defer func() {
_ = response.Body.Close()
}()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", errorutil.New("Error reading /metrics response body: %v", err)
}
return string(body), nil
}
url := fmt.Sprintf("http://127.0.0.1:%v/metrics", s.Options.ListenPort)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
if err := callback(metricCallback(url)); err != nil {
return
}
}
}
}()
}