-
-
Notifications
You must be signed in to change notification settings - Fork 121
/
statsviz.go
283 lines (250 loc) · 8.14 KB
/
statsviz.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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
// Package statsviz allows visualizing Go runtime metrics data in real time in
// your browser.
//
// Register a Statsviz HTTP handlers with your server's [http.ServeMux]
// (preferred method):
//
// mux := http.NewServeMux()
// statsviz.Register(mux)
//
// Alternatively, you can register with [http.DefaultServeMux]:
//
// ss := statsviz.Server{}
// s.Register(http.DefaultServeMux)
//
// By default, Statsviz is served at http://host:port/debug/statsviz/. This, and
// other settings, can be changed by passing some [Option] to [NewServer].
//
// If your application is not already running an HTTP server, you need to start
// one. Add "net/http" and "log" to your imports, and use the following code in
// your main function:
//
// go func() {
// log.Println(http.ListenAndServe("localhost:8080", nil))
// }()
//
// Then open your browser and visit http://localhost:8080/debug/statsviz/.
//
// # Advanced usage:
//
// If you want more control over Statsviz HTTP handlers, examples are:
// - you're using some HTTP framework
// - you want to place Statsviz handler behind some middleware
//
// then use [NewServer] to obtain a [Server] instance. Both the [Server.Index] and
// [Server.Ws]() methods return [http.HandlerFunc].
//
// srv, err := statsviz.NewServer(); // Create server or handle error
// srv.Index() // UI (dashboard) http.HandlerFunc
// srv.Ws() // Websocket http.HandlerFunc
package statsviz
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/arl/statsviz/internal/plot"
"github.com/arl/statsviz/internal/static"
)
const (
defaultRoot = "/debug/statsviz"
defaultSendInterval = time.Second
)
// RegisterDefault registers the Statsviz HTTP handlers on [http.DefaultServeMux].
//
// RegisterDefault should not be used in production.
func RegisterDefault(opts ...Option) error {
return Register(http.DefaultServeMux, opts...)
}
// Register registers the Statsviz HTTP handlers on the provided mux.
func Register(mux *http.ServeMux, opts ...Option) error {
srv, err := NewServer(opts...)
if err != nil {
return err
}
srv.Register(mux)
return nil
}
// Server is the core component of Statsviz. It collects and periodically
// updates metrics data and provides two essential HTTP handlers:
// - the Index handler serves Statsviz user interface, allowing you to
// visualize runtime metrics on your browser.
// - The Ws handler establishes a WebSocket connection allowing the connected
// browser to receive metrics updates from the server.
//
// The zero value is not a valid Server, use NewServer to create a valid one.
type Server struct {
intv time.Duration // interval between consecutive metrics emission
root string // HTTP path root
plots *plot.List // plots shown on the user interface
userPlots []plot.UserPlot
}
// NewServer constructs a new Statsviz Server with the provided options, or the
// default settings.
//
// Note that once the server is created, its HTTP handlers needs to be registered
// with some HTTP server. You can either use the Register method or register yourself
// the Index and Ws handlers.
func NewServer(opts ...Option) (*Server, error) {
s := &Server{
intv: defaultSendInterval,
root: defaultRoot,
}
for _, opt := range opts {
if err := opt(s); err != nil {
return nil, err
}
}
pl, err := plot.NewList(s.userPlots)
if err != nil {
return nil, err
}
s.plots = pl
return s, nil
}
// Option is a configuration option for the Server.
type Option func(*Server) error
// SendFrequency changes the interval between successive acquisitions of metrics
// and their sending to the user interface. The default interval is one second.
func SendFrequency(intv time.Duration) Option {
return func(s *Server) error {
if intv <= 0 {
return fmt.Errorf("frequency must be a positive integer")
}
s.intv = intv
return nil
}
}
// Root changes the root path of the Statsviz user interface.
// The default is "/debug/statsviz".
func Root(path string) Option {
return func(s *Server) error {
s.root = path
return nil
}
}
// TimeseriesPlot adds a new time series plot to Statsviz. This options can
// be added multiple times.
func TimeseriesPlot(tsp TimeSeriesPlot) Option {
return func(s *Server) error {
s.userPlots = append(s.userPlots, plot.UserPlot{Scatter: tsp.timeseries})
return nil
}
}
// Register registers the Statsviz HTTP handlers on the provided mux.
func (s *Server) Register(mux *http.ServeMux) {
mux.Handle(s.root+"/", s.Index())
mux.HandleFunc(s.root+"/ws", s.Ws())
}
// intercept is a middleware that intercepts requests for plotsdef.js, which is
// generated dynamically based on the plots configuration. Other requests are
// forwarded as-is.
func intercept(h http.Handler, cfg *plot.Config) http.HandlerFunc {
buf := bytes.Buffer{}
buf.WriteString("export default ")
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
if err := enc.Encode(cfg); err != nil {
panic("unexpected failure to encode plot definitions: " + err.Error())
}
buf.WriteString(";")
plotsdefjs := buf.Bytes()
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "js/plotsdef.js" {
w.Header().Add("Content-Length", strconv.Itoa(buf.Len()))
w.Header().Add("Content-Type", "text/javascript; charset=utf-8")
w.Write(plotsdefjs)
return
}
// Force Content-Type if needed.
if ct, ok := contentTypes[r.URL.Path]; ok {
w.Header().Add("Content-Type", ct)
}
h.ServeHTTP(w, r)
}
}
// contentTypes forces the Content-Type HTTP header for certain files of some
// JavaScript libraries that have no extensions. Otherwise, the HTTP file server
// would serve them with "Content-Type: text/plain".
var contentTypes = map[string]string{
"libs/js/popperjs-core2": "text/javascript",
"libs/js/tippy.js@6": "text/javascript",
}
// Returns an FS serving the embedded assets, or the assets directory if
// STATSVIZ_DEBUG contains the 'asssets' key.
func assetsFS() http.FileSystem {
assets := http.FS(static.Assets)
vdbg := os.Getenv("STATSVIZ_DEBUG")
if vdbg == "" {
return assets
}
kvs := strings.Split(vdbg, ";")
for _, kv := range kvs {
k, v, found := strings.Cut(strings.TrimSpace(kv), "=")
if !found {
panic("invalid STATSVIZ_DEBUG value: " + kv)
}
if k == "assets" {
dir := filepath.Join(v)
return http.Dir(dir)
}
}
return assets
}
// Index returns the index handler, which responds with the Statsviz user
// interface HTML page. By default, the handler is served at the path specified
// by the root. Use [WithRoot] to change the path.
func (s *Server) Index() http.HandlerFunc {
prefix := strings.TrimSuffix(s.root, "/") + "/"
assets := http.FileServer(assetsFS())
handler := intercept(assets, s.plots.Config())
return http.StripPrefix(prefix, handler).ServeHTTP
}
// Ws returns the WebSocket handler used by Statsviz to send application
// metrics. The underlying net.Conn is used to upgrade the HTTP server
// connection to the WebSocket protocol.
func (s *Server) Ws() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
// Ignore this error. This happens when the other end connection closes,
// for example. We can't handle it in any meaningful way anyways.
_ = s.sendStats(ws, s.intv)
}
}
// sendStats sends runtime statistics over the WebSocket connection.
func (s *Server) sendStats(conn *websocket.Conn, frequency time.Duration) error {
tick := time.NewTicker(frequency)
defer tick.Stop()
// If the WebSocket connection is initiated by an already open web UI
// (started by a previous process, for example), then plotsdef.js won't be
// requested. Call plots.Config() manually to ensure that s.plots internals
// are correctly initialized.
s.plots.Config()
for range tick.C {
w, err := conn.NextWriter(websocket.TextMessage)
if err != nil {
return err
}
if err := s.plots.WriteValues(w); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
}
panic("unreachable")
}