feat: add IM service health and metrics endpoints
This commit is contained in:
@@ -223,9 +223,9 @@ RUN mkdir /botdata
|
|||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
RUN chmod +x credentials.sh
|
RUN chmod +x credentials.sh
|
||||||
|
|
||||||
# Healthcheck: check if port 6060 is open
|
# Healthcheck: verify the process and database dependency are responsive.
|
||||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
|
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
|
||||||
CMD nc -z localhost 6060 || exit 1
|
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
|
||||||
|
|
||||||
# Generate config from template and run the server.
|
# Generate config from template and run the server.
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ RUN mkdir -p /botdata static \
|
|||||||
&& chmod +x entrypoint.sh credentials.sh tinode init-db apply-hotgo-tenant-db
|
&& chmod +x entrypoint.sh credentials.sh tinode init-db apply-hotgo-tenant-db
|
||||||
|
|
||||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
|
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
|
||||||
CMD nc -z localhost 6060 || exit 1
|
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error
|
|||||||
dsn = cfg.FormatDSN()
|
dsn = cfg.FormatDSN()
|
||||||
dbName = cfg.DBName
|
dbName = cfg.DBName
|
||||||
}
|
}
|
||||||
db, err := sqlx.Open("mysql", dsn)
|
db, err := sqlx.Open(instrumentedMySQLDriverName, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
@@ -687,7 +687,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
|||||||
// Clear database name
|
// Clear database name
|
||||||
cfg.DBName = ""
|
cfg.DBName = ""
|
||||||
|
|
||||||
a.db, err = sqlx.Open("mysql", cfg.FormatDSN())
|
a.db, err = sqlx.Open(instrumentedMySQLDriverName, cfg.FormatDSN())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//go:build mysql
|
||||||
|
// +build mysql
|
||||||
|
|
||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ms "github.com/go-sql-driver/mysql"
|
||||||
|
immetrics "github.com/tinode/chat/server/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
const instrumentedMySQLDriverName = "meetingim_mysql_metrics"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sql.Register(instrumentedMySQLDriverName, instrumentedMySQLDriver{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type instrumentedMySQLDriver struct{}
|
||||||
|
|
||||||
|
func (instrumentedMySQLDriver) Open(dsn string) (driver.Conn, error) {
|
||||||
|
connector, err := instrumentedMySQLDriver{}.OpenConnector(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return connector.Connect(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instrumentedMySQLDriver) OpenConnector(dsn string) (driver.Connector, error) {
|
||||||
|
cfg, err := ms.ParseDSN(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
connector, err := ms.NewConnector(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return instrumentedConnector{connector: connector}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type instrumentedConnector struct {
|
||||||
|
connector driver.Connector
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||||
|
conn, err := c.connector.Connect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return instrumentedConn{conn: conn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConnector) Driver() driver.Driver {
|
||||||
|
return instrumentedMySQLDriver{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type instrumentedConn struct {
|
||||||
|
conn driver.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) Prepare(query string) (driver.Stmt, error) {
|
||||||
|
stmt, err := c.conn.Prepare(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return instrumentedStmt{stmt: stmt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) Close() error {
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) Begin() (driver.Tx, error) {
|
||||||
|
started := time.Now()
|
||||||
|
tx, err := c.conn.Begin()
|
||||||
|
immetrics.ObserveDB("mysql", "begin", started, err)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return instrumentedTx{tx: tx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) Ping(ctx context.Context) error {
|
||||||
|
pinger, ok := c.conn.(driver.Pinger)
|
||||||
|
if !ok {
|
||||||
|
return driver.ErrSkip
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
err := pinger.Ping(ctx)
|
||||||
|
immetrics.ObserveDB("mysql", "ping", started, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
||||||
|
execer, ok := c.conn.(driver.ExecerContext)
|
||||||
|
if !ok {
|
||||||
|
return nil, driver.ErrSkip
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
result, err := execer.ExecContext(ctx, query, args)
|
||||||
|
immetrics.ObserveDB("mysql", "exec", started, err)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
queryer, ok := c.conn.(driver.QueryerContext)
|
||||||
|
if !ok {
|
||||||
|
return nil, driver.ErrSkip
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
rows, err := queryer.QueryContext(ctx, query, args)
|
||||||
|
immetrics.ObserveDB("mysql", "query", started, err)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
|
||||||
|
beginner, ok := c.conn.(driver.ConnBeginTx)
|
||||||
|
if !ok {
|
||||||
|
return nil, driver.ErrSkip
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
tx, err := beginner.BeginTx(ctx, opts)
|
||||||
|
immetrics.ObserveDB("mysql", "begin", started, err)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return instrumentedTx{tx: tx}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) {
|
||||||
|
preparer, ok := c.conn.(driver.ConnPrepareContext)
|
||||||
|
if !ok {
|
||||||
|
return nil, driver.ErrSkip
|
||||||
|
}
|
||||||
|
stmt, err := preparer.PrepareContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return instrumentedStmt{stmt: stmt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) CheckNamedValue(value *driver.NamedValue) error {
|
||||||
|
checker, ok := c.conn.(driver.NamedValueChecker)
|
||||||
|
if !ok {
|
||||||
|
return driver.ErrSkip
|
||||||
|
}
|
||||||
|
return checker.CheckNamedValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) ResetSession(ctx context.Context) error {
|
||||||
|
resetter, ok := c.conn.(driver.SessionResetter)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resetter.ResetSession(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c instrumentedConn) IsValid() bool {
|
||||||
|
validator, ok := c.conn.(driver.Validator)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return validator.IsValid()
|
||||||
|
}
|
||||||
|
|
||||||
|
type instrumentedStmt struct {
|
||||||
|
stmt driver.Stmt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s instrumentedStmt) Close() error {
|
||||||
|
return s.stmt.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s instrumentedStmt) NumInput() int {
|
||||||
|
return s.stmt.NumInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s instrumentedStmt) Exec(args []driver.Value) (driver.Result, error) {
|
||||||
|
started := time.Now()
|
||||||
|
result, err := s.stmt.Exec(args)
|
||||||
|
immetrics.ObserveDB("mysql", "exec", started, err)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s instrumentedStmt) Query(args []driver.Value) (driver.Rows, error) {
|
||||||
|
started := time.Now()
|
||||||
|
rows, err := s.stmt.Query(args)
|
||||||
|
immetrics.ObserveDB("mysql", "query", started, err)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s instrumentedStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) {
|
||||||
|
execer, ok := s.stmt.(driver.StmtExecContext)
|
||||||
|
if !ok {
|
||||||
|
return nil, driver.ErrSkip
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
result, err := execer.ExecContext(ctx, args)
|
||||||
|
immetrics.ObserveDB("mysql", "exec", started, err)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s instrumentedStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
queryer, ok := s.stmt.(driver.StmtQueryContext)
|
||||||
|
if !ok {
|
||||||
|
return nil, driver.ErrSkip
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
rows, err := queryer.QueryContext(ctx, args)
|
||||||
|
immetrics.ObserveDB("mysql", "query", started, err)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s instrumentedStmt) CheckNamedValue(value *driver.NamedValue) error {
|
||||||
|
checker, ok := s.stmt.(driver.NamedValueChecker)
|
||||||
|
if !ok {
|
||||||
|
return driver.ErrSkip
|
||||||
|
}
|
||||||
|
return checker.CheckNamedValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
type instrumentedTx struct {
|
||||||
|
tx driver.Tx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx instrumentedTx) Commit() error {
|
||||||
|
started := time.Now()
|
||||||
|
err := tx.tx.Commit()
|
||||||
|
immetrics.ObserveDB("mysql", "commit", started, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx instrumentedTx) Rollback() error {
|
||||||
|
started := time.Now()
|
||||||
|
err := tx.tx.Rollback()
|
||||||
|
immetrics.ObserveDB("mysql", "rollback", started, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ driver.Driver = instrumentedMySQLDriver{}
|
||||||
|
_ driver.DriverContext = instrumentedMySQLDriver{}
|
||||||
|
_ driver.Connector = instrumentedConnector{}
|
||||||
|
_ driver.Conn = instrumentedConn{}
|
||||||
|
_ driver.Pinger = instrumentedConn{}
|
||||||
|
_ driver.ExecerContext = instrumentedConn{}
|
||||||
|
_ driver.QueryerContext = instrumentedConn{}
|
||||||
|
_ driver.ConnBeginTx = instrumentedConn{}
|
||||||
|
_ driver.ConnPrepareContext = instrumentedConn{}
|
||||||
|
_ driver.NamedValueChecker = instrumentedConn{}
|
||||||
|
_ driver.SessionResetter = instrumentedConn{}
|
||||||
|
_ driver.Validator = instrumentedConn{}
|
||||||
|
_ driver.Stmt = instrumentedStmt{}
|
||||||
|
_ driver.StmtExecContext = instrumentedStmt{}
|
||||||
|
_ driver.StmtQueryContext = instrumentedStmt{}
|
||||||
|
_ driver.NamedValueChecker = instrumentedStmt{}
|
||||||
|
_ driver.Tx = instrumentedTx{}
|
||||||
|
)
|
||||||
+3
-1
@@ -847,6 +847,8 @@ func main() {
|
|||||||
logs.Info.Printf("Server status is available at '%s'", sspath)
|
logs.Info.Printf("Server status is available at '%s'", sspath)
|
||||||
mux.HandleFunc(sspath, serveStatus)
|
mux.HandleFunc(sspath, serveStatus)
|
||||||
}
|
}
|
||||||
|
mux.HandleFunc("/healthz", serveHealthz)
|
||||||
|
mux.HandleFunc("/metrics", serveMetrics)
|
||||||
|
|
||||||
// Handle websocket clients.
|
// Handle websocket clients.
|
||||||
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
|
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
|
||||||
@@ -946,7 +948,7 @@ func main() {
|
|||||||
mux.HandleFunc("/", serve404)
|
mux.HandleFunc("/", serve404)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = listenAndServe(config.Listen, withIMHTTPProtocol(config.ApiPath+"v1/im/", mux), tlsConfig, signalHandler()); err != nil {
|
if err = listenAndServe(config.Listen, monitorHTTP(withIMHTTPProtocol(config.ApiPath+"v1/im/", mux)), tlsConfig, signalHandler()); err != nil {
|
||||||
logs.Err.Fatal(err)
|
logs.Err.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"expvar"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Registry = prometheus.NewRegistry()
|
||||||
|
|
||||||
|
HTTPRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "http_requests_total",
|
||||||
|
Help: "Total number of HTTP requests by method, normalized path, and status.",
|
||||||
|
}, []string{"method", "path", "status"})
|
||||||
|
|
||||||
|
HTTPRequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "http_request_duration_seconds",
|
||||||
|
Help: "HTTP request duration by method, normalized path, and status.",
|
||||||
|
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||||
|
}, []string{"method", "path", "status"})
|
||||||
|
|
||||||
|
DBQueryDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "db_query_duration_seconds",
|
||||||
|
Help: "Database operation duration by driver, operation, and status.",
|
||||||
|
Buckets: []float64{0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
|
||||||
|
}, []string{"driver", "operation", "status"})
|
||||||
|
|
||||||
|
WSConnectionsActive = prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "ws_connections_active",
|
||||||
|
Help: "Current active WebSocket connections.",
|
||||||
|
}, func() float64 {
|
||||||
|
return float64(expvarIntValue("LiveSessions"))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Registry.MustRegister(WSConnectionsActive, HTTPRequestsTotal, HTTPRequestDuration, DBQueryDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ObserveDB(driverName, operation string, started time.Time, err error) {
|
||||||
|
status := "ok"
|
||||||
|
if err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
DBQueryDuration.WithLabelValues(driverName, operation, status).Observe(time.Since(started).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func expvarIntValue(name string) int64 {
|
||||||
|
value := expvar.Get(name)
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(value.String(), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
immetrics "github.com/tinode/chat/server/metrics"
|
||||||
|
"github.com/tinode/chat/server/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsHandler = promhttp.HandlerFor(immetrics.Registry, promhttp.HandlerOpts{})
|
||||||
|
uuidLikeMetricSegment = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F-]{13,}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type healthzResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Checks map[string]string `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveHealthz(wrt http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||||
|
wrt.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
|
||||||
|
http.Error(wrt, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
checks := map[string]string{}
|
||||||
|
status := http.StatusOK
|
||||||
|
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := store.Store.HealthCheck(ctx); err != nil {
|
||||||
|
checks["mysql"] = err.Error()
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
} else {
|
||||||
|
checks["mysql"] = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
body := healthzResponse{Status: "ok", Checks: checks}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
body.Status = "error"
|
||||||
|
}
|
||||||
|
wrt.WriteHeader(status)
|
||||||
|
if req.Method != http.MethodHead {
|
||||||
|
_ = json.NewEncoder(wrt).Encode(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveMetrics(wrt http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||||
|
wrt.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
|
||||||
|
http.Error(wrt, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metricsHandler.ServeHTTP(wrt, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitorHTTP(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path == "/metrics" {
|
||||||
|
next.ServeHTTP(wrt, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
rec := &monitoringResponseWriter{ResponseWriter: wrt, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(rec, req)
|
||||||
|
status := strconv.Itoa(rec.status)
|
||||||
|
path := normalizeMetricPath(req.URL.Path)
|
||||||
|
immetrics.HTTPRequestsTotal.WithLabelValues(req.Method, path, status).Inc()
|
||||||
|
immetrics.HTTPRequestDuration.WithLabelValues(req.Method, path, status).Observe(time.Since(started).Seconds())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type monitoringResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *monitoringResponseWriter) WriteHeader(status int) {
|
||||||
|
w.status = status
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *monitoringResponseWriter) Flush() {
|
||||||
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *monitoringResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
h, ok := w.ResponseWriter.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, http.ErrNotSupported
|
||||||
|
}
|
||||||
|
return h.Hijack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *monitoringResponseWriter) Push(target string, opts *http.PushOptions) error {
|
||||||
|
p, ok := w.ResponseWriter.(http.Pusher)
|
||||||
|
if !ok {
|
||||||
|
return http.ErrNotSupported
|
||||||
|
}
|
||||||
|
return p.Push(target, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *monitoringResponseWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||||
|
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
|
||||||
|
return rf.ReadFrom(r)
|
||||||
|
}
|
||||||
|
return io.Copy(w.ResponseWriter, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMetricPath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMetricDynamicSegment(part) {
|
||||||
|
parts[i] = "{id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMetricDynamicSegment(segment string) bool {
|
||||||
|
if strings.HasPrefix(segment, "usr") || strings.HasPrefix(segment, "grp") || strings.HasPrefix(segment, "p2p") || strings.HasPrefix(segment, "chn") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := strconv.ParseInt(segment, 10, 64); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if uuidLikeMetricSegment.MatchString(segment) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
immetrics "github.com/tinode/chat/server/metrics"
|
||||||
|
"github.com/tinode/chat/server/store"
|
||||||
|
"github.com/tinode/chat/server/store/mock_store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeHealthzOK(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||||
|
oldStore := store.Store
|
||||||
|
store.Store = mockStore
|
||||||
|
t.Cleanup(func() { store.Store = oldStore })
|
||||||
|
mockStore.EXPECT().HealthCheck(gomock.Any()).Return(nil)
|
||||||
|
|
||||||
|
wrt := httptest.NewRecorder()
|
||||||
|
serveHealthz(wrt, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
|
||||||
|
if wrt.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status %d body=%s", wrt.Code, wrt.Body.String())
|
||||||
|
}
|
||||||
|
if body := wrt.Body.String(); !strings.Contains(body, `"status":"ok"`) || !strings.Contains(body, `"mysql":"ok"`) {
|
||||||
|
t.Fatalf("unexpected body %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeHealthzError(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||||
|
oldStore := store.Store
|
||||||
|
store.Store = mockStore
|
||||||
|
t.Cleanup(func() { store.Store = oldStore })
|
||||||
|
mockStore.EXPECT().HealthCheck(gomock.Any()).Return(errors.New("dial timeout"))
|
||||||
|
|
||||||
|
wrt := httptest.NewRecorder()
|
||||||
|
serveHealthz(wrt, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
|
||||||
|
if wrt.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("unexpected status %d body=%s", wrt.Code, wrt.Body.String())
|
||||||
|
}
|
||||||
|
if body := wrt.Body.String(); !strings.Contains(body, `"status":"error"`) || !strings.Contains(body, `"mysql":"dial timeout"`) {
|
||||||
|
t.Fatalf("unexpected body %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeMetricsIncludesWSConnections(t *testing.T) {
|
||||||
|
wrt := httptest.NewRecorder()
|
||||||
|
serveMetrics(wrt, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
|
||||||
|
if wrt.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status %d body=%s", wrt.Code, wrt.Body.String())
|
||||||
|
}
|
||||||
|
body := wrt.Body.String()
|
||||||
|
if !strings.Contains(body, "# TYPE ws_connections_active gauge") || !strings.Contains(body, "ws_connections_active ") {
|
||||||
|
t.Fatalf("unexpected body %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitorHTTPRecordsRequestMetrics(t *testing.T) {
|
||||||
|
handler := monitorHTTP(http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
|
||||||
|
wrt.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
|
||||||
|
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/im/users/1234567890123456789/profile", nil))
|
||||||
|
wrt := httptest.NewRecorder()
|
||||||
|
serveMetrics(wrt, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
|
||||||
|
body := wrt.Body.String()
|
||||||
|
if !strings.Contains(body, `http_requests_total{method="POST",path="/v1/im/users/{id}/profile",status="201"} 1`) {
|
||||||
|
t.Fatalf("request counter was not exported: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `http_request_duration_seconds_bucket{method="POST",path="/v1/im/users/{id}/profile",status="201"`) {
|
||||||
|
t.Fatalf("request duration was not exported: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeMetricsIncludesDBQueryDuration(t *testing.T) {
|
||||||
|
immetrics.ObserveDB("mysql", "query", time.Now().Add(-time.Millisecond), nil)
|
||||||
|
|
||||||
|
wrt := httptest.NewRecorder()
|
||||||
|
serveMetrics(wrt, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
|
||||||
|
body := wrt.Body.String()
|
||||||
|
if !strings.Contains(body, `db_query_duration_seconds_bucket{driver="mysql",operation="query",status="ok"`) {
|
||||||
|
t.Fatalf("db query duration was not exported: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,18 +77,27 @@ func statsInit(mux *http.ServeMux, path string) {
|
|||||||
|
|
||||||
func statsRegisterDbStats() {
|
func statsRegisterDbStats() {
|
||||||
if f := store.Store.DbStats(); f != nil {
|
if f := store.Store.DbStats(); f != nil {
|
||||||
|
if expvar.Get("DbStats") != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
expvar.Publish("DbStats", expvar.Func(f))
|
expvar.Publish("DbStats", expvar.Func(f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register integer variable. Don't check for initialization.
|
// Register integer variable. Don't check for initialization.
|
||||||
func statsRegisterInt(name string) {
|
func statsRegisterInt(name string) {
|
||||||
|
if expvar.Get(name) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
expvar.Publish(name, new(expvar.Int))
|
expvar.Publish(name, new(expvar.Int))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register histogram variable. `bounds` specifies histogram buckets/bins
|
// Register histogram variable. `bounds` specifies histogram buckets/bins
|
||||||
// (see comment next to the `histogram` struct definition).
|
// (see comment next to the `histogram` struct definition).
|
||||||
func statsRegisterHistogram(name string, bounds []float64) {
|
func statsRegisterHistogram(name string, bounds []float64) {
|
||||||
|
if expvar.Get(name) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
numBuckets := len(bounds) + 1
|
numBuckets := len(bounds) + 1
|
||||||
expvar.Publish(name, &histogram{
|
expvar.Publish(name, &histogram{
|
||||||
CountPerBucket: make([]int64, numBuckets),
|
CountPerBucket: make([]int64, numBuckets),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package mock_store
|
package mock_store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
context "context"
|
||||||
json "encoding/json"
|
json "encoding/json"
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
time "time"
|
time "time"
|
||||||
@@ -138,6 +139,20 @@ func (mr *MockPersistentStorageInterfaceMockRecorder) GetAuthNames() *gomock.Cal
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthNames", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAuthNames))
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthNames", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAuthNames))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HealthCheck mocks base method.
|
||||||
|
func (m *MockPersistentStorageInterface) HealthCheck(ctx context.Context) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "HealthCheck", ctx)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck indicates an expected call of HealthCheck.
|
||||||
|
func (mr *MockPersistentStorageInterfaceMockRecorder) HealthCheck(ctx interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HealthCheck", reflect.TypeOf((*MockPersistentStorageInterface)(nil).HealthCheck), ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// GetDbVersion mocks base method.
|
// GetDbVersion mocks base method.
|
||||||
func (m *MockPersistentStorageInterface) GetDbVersion() int {
|
func (m *MockPersistentStorageInterface) GetDbVersion() int {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -120,6 +121,7 @@ type PersistentStorageInterface interface {
|
|||||||
GetUid() types.Uid
|
GetUid() types.Uid
|
||||||
GetUidString() string
|
GetUidString() string
|
||||||
DbStats() func() any
|
DbStats() func() any
|
||||||
|
HealthCheck(context.Context) error
|
||||||
GetAuthNames() []string
|
GetAuthNames() []string
|
||||||
GetAuthHandler(name string) auth.AuthHandler
|
GetAuthHandler(name string) auth.AuthHandler
|
||||||
GetLogicalAuthHandler(name string) auth.AuthHandler
|
GetLogicalAuthHandler(name string) auth.AuthHandler
|
||||||
@@ -473,6 +475,19 @@ func (storeObj) GetAdapter() adapter.Adapter {
|
|||||||
return adp
|
return adp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HealthCheck verifies that the persistent storage adapter can execute a real operation.
|
||||||
|
func (storeObj) HealthCheck(ctx context.Context) error {
|
||||||
|
if adp == nil || !adp.IsOpen() {
|
||||||
|
return errors.New("store is not open")
|
||||||
|
}
|
||||||
|
if checker, ok := adp.(interface {
|
||||||
|
HealthCheck(context.Context) error
|
||||||
|
}); ok {
|
||||||
|
return checker.HealthCheck(ctx)
|
||||||
|
}
|
||||||
|
return errors.New("health check is not supported by " + adp.GetName())
|
||||||
|
}
|
||||||
|
|
||||||
// GetAdapterName returns the name of the current adater.
|
// GetAdapterName returns the name of the current adater.
|
||||||
func (storeObj) GetAdapterName() string {
|
func (storeObj) GetAdapterName() string {
|
||||||
if adp != nil {
|
if adp != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user