Repository: creachadair/jrpc2
Pull request #89 (2022-10-30 19:03:15)

Replace idiosyncratic metrics collector with expvar.
This change removes the "metrics" package from the module and updates the
existing metrics to use the standard expvar package instead.  The package
creates a shared *expvar.Map at initialization time, and populates expvar
metrics for the existing server details (request counts, bytes read/written,
active servers, etc.).

This is a breaking change to the module API.

Updates #46.


Patch:
diff --git a/README.md b/README.md
index 2f7b0db..9e05651 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,6 @@ There is also a working [example in the Go playground](https://go.dev/play/p/Gdd
 
 *  Package [jhttp](http://godoc.org/github.com/creachadair/jrpc2/jhttp) allows clients and servers to use HTTP as a transport.
 
-*  Package [metrics](http://godoc.org/github.com/creachadair/jrpc2/metrics) defines a server metrics collector.
-
 *  Package [server](http://godoc.org/github.com/creachadair/jrpc2/server) provides support for running a server to handle multiple connections, and an in-memory implementation for testing.
 
 [spec]: http://www.jsonrpc.org/specification
diff --git a/metrics/metrics.go b/metrics/metrics.go
deleted file mode 100644
index d9a28c2..0000000
--- a/metrics/metrics.go
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2017 Michael J. Fromberger. All Rights Reserved.
-
-// Package metrics defines a concurrently-accessible metrics collector.
-//
-// A *metrics.M value exports methods to track integer counters and maximum
-// values. A metric has a caller-assigned string name that is not interpreted
-// by the collector except to locate its stored value.
-package metrics
-
-import "sync"
-
-// An M collects counters and maximum value trackers.  A nil *M is valid, and
-// discards all metrics. The methods of an *M are safe for concurrent use by
-// multiple goroutines.
-type M struct {
-	mu      sync.Mutex
-	counter map[string]int64
-	maxVal  map[string]int64
-	label   map[string]any
-}
-
-// New creates a new, empty metrics collector.
-func New() *M {
-	return &M{
-		counter: make(map[string]int64),
-		maxVal:  make(map[string]int64),
-		label:   make(map[string]any),
-	}
-}
-
-// Count adds n to the current value of the counter named, defining the counter
-// if it does not already exist.
-func (m *M) Count(name string, n int64) {
-	if m != nil {
-		m.mu.Lock()
-		defer m.mu.Unlock()
-		m.counter[name] += n
-	}
-}
-
-// SetMaxValue sets the maximum value metric named to the greater of n and its
-// current value, defining the value if it does not already exist.
-func (m *M) SetMaxValue(name string, n int64) {
-	if m != nil {
-		m.mu.Lock()
-		defer m.mu.Unlock()
-		if old, ok := m.maxVal[name]; !ok || n > old {
-			m.maxVal[name] = n
-		}
-	}
-}
-
-// CountAndSetMax adds n to the current value of the counter named, and also
-// updates a max value tracker with the same name in a single step.
-func (m *M) CountAndSetMax(name string, n int64) {
-	if m != nil {
-		m.mu.Lock()
-		defer m.mu.Unlock()
-		if old, ok := m.maxVal[name]; !ok || n > old {
-			m.maxVal[name] = n
-		}
-		m.counter[name] += n
-	}
-}
-
-// SetLabel sets the specified label to value. If value == nil the label is
-// removed from the set.
-//
-// As a special case, if value has the concrete type
-//
-//	func() any
-//
-// then the value of the label is obtained by calling that function.
-func (m *M) SetLabel(name string, value any) {
-	if m != nil {
-		m.mu.Lock()
-		defer m.mu.Unlock()
-		if value == nil {
-			delete(m.label, name)
-		} else {
-			m.label[name] = value
-		}
-	}
-}
-
-// EditLabel calls edit with the current value of the specified label.
-// The value returned by edit replaces the contents of the label.
-// If edit returns nil, the label is removed from the set.
-// If the label did not exist, the argument to edit is nil.
-func (m *M) EditLabel(name string, edit func(any) any) {
-	if m != nil {
-		m.mu.Lock()
-		defer m.mu.Unlock()
-		newValue := edit(m.label[name])
-		if newValue == nil {
-			delete(m.label, name)
-		} else {
-			m.label[name] = newValue
-		}
-	}
-}
-
-// Snapshot copies an atomic snapshot of the collected metrics into the non-nil
-// fields of the provided snapshot value. Only the fields of snap that are not
-// nil are snapshotted.
-func (m *M) Snapshot(snap Snapshot) {
-	if m != nil {
-		m.mu.Lock()
-		defer m.mu.Unlock()
-		if c := snap.Counter; c != nil {
-			for name, val := range m.counter {
-				c[name] = val
-			}
-		}
-		if v := snap.MaxValue; v != nil {
-			for name, val := range m.maxVal {
-				v[name] = val
-			}
-		}
-		if v := snap.Label; v != nil {
-			for name, val := range m.label {
-				if fn, ok := val.(func() any); ok {
-					v[name] = fn()
-				} else {
-					v[name] = val
-				}
-			}
-		}
-	}
-}
-
-// A Snapshot represents a point-in-time snapshot of a metrics collector.  The
-// fields of this type are filled in by the Snapshot method of *M.
-type Snapshot struct {
-	Counter  map[string]int64
-	MaxValue map[string]int64
-	Label    map[string]any
-}
diff --git a/opts.go b/opts.go
index 52ef4e4..ce7f593 100644
--- a/opts.go
+++ b/opts.go
@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"github.com/creachadair/jrpc2/code"
-	"github.com/creachadair/jrpc2/metrics"
 )
 
 // ServerOptions control the behaviour of a server created by NewServer.
@@ -46,11 +45,6 @@ type ServerOptions struct {
 	// If unset, the server uses a background context.
 	NewContext func() context.Context
 
-	// If set, use this value to record server metrics. All servers created
-	// from the same options will share the same metrics collector.  If none is
-	// set, an empty collector will be created for each new server.
-	Metrics *metrics.M
-
 	// If nonzero this value as the server start time; otherwise, use the
 	// current time when Start is called. All servers created from the same
 	// options will share the same start time if one is set.
@@ -88,13 +82,6 @@ func (o *ServerOptions) newContext() func() context.Context {
 	return o.NewContext
 }
 
-func (s *ServerOptions) metrics() *metrics.M {
-	if s == nil || s.Metrics == nil {
-		return metrics.New()
-	}
-	return s.Metrics
-}
-
 func (s *ServerOptions) rpcLog() RPCLogger {
 	if s == nil || s.RPCLog == nil {
 		return nullRPCLogger{}
diff --git a/server.go b/server.go
index edfdce1..c6b17e0 100644
--- a/server.go
+++ b/server.go
@@ -6,6 +6,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"expvar"
 	"io"
 	"strconv"
 	"strings"
@@ -14,10 +15,40 @@ import (
 
 	"github.com/creachadair/jrpc2/channel"
 	"github.com/creachadair/jrpc2/code"
-	"github.com/creachadair/jrpc2/metrics"
 	"golang.org/x/sync/semaphore"
 )
 
+var (
+	serverMetrics = new(expvar.Map)
+
+	serversActiveGauge     = new(expvar.Int)
+	rpcRequestsCount       = new(expvar.Int)
+	rpcErrorsCount         = new(expvar.Int)
+	bytesReadCount         = new(expvar.Int)
+	bytesWrittenCount      = new(expvar.Int)
+	rpcCallsPushed         = new(expvar.Int)
+	rpcNotificationsPushed = new(expvar.Int)
+)
+
+func init() {
+	serverMetrics.Set("servers_active", serversActiveGauge)
+	serverMetrics.Set("rpc_requests", rpcRequestsCount)
+	serverMetrics.Set("rpc_errors", rpcErrorsCount)
+	serverMetrics.Set("bytes_read", bytesReadCount)
+	serverMetrics.Set("bytes_written", bytesWrittenCount)
+	serverMetrics.Set("calls_pushed", rpcCallsPushed)
+	serverMetrics.Set("notifications_pushed", rpcNotificationsPushed)
+}
+
+// ServerMetrics returns a map of exported server metrics for use with the
+// expvar package. This map is shared among all server instances created by
+// NewServer. The caller is free to add or remove metrics in the map, but note
+// that such changes will affect all servers.
+//
+// The caller is responsible for publishing the metrics to the exporter via
+// expvar.Publish or similar.
+func ServerMetrics() *expvar.Map { return serverMetrics }
+
 // A Server is a JSON-RPC 2.0 server. The server receives requests and sends
 // responses on a channel.Channel provided by the caller, and dispatches
 // requests to user-defined Handlers.
@@ -31,7 +62,6 @@ type Server struct {
 	log     func(string, ...any)   // write debug logs here
 	rpcLog  RPCLogger              // log RPC requests and responses here
 	newctx  func() context.Context // create a new base request context
-	metrics *metrics.M             // metrics collected during execution
 	start   time.Time              // when Start was called
 	builtin bool                   // whether built-in rpc.* methods are enabled
 
@@ -72,7 +102,6 @@ func NewServer(mux Assigner, opts *ServerOptions) *Server {
 		rpcLog:  opts.rpcLog(),
 		newctx:  opts.newContext(),
 		mu:      new(sync.Mutex),
-		metrics: opts.metrics(),
 		start:   opts.startTime(),
 		builtin: opts.allowBuiltin(),
 		inq:     newQueue(),
@@ -98,7 +127,7 @@ func (s *Server) Start(c channel.Channel) *Server {
 	if s.start.IsZero() {
 		s.start = time.Now().In(time.UTC)
 	}
-	s.metrics.Count("rpc.serversActive", 1)
+	serversActiveGauge.Add(1)
 
 	// Reset all the I/O structures and start up the workers.
 	s.err = nil
@@ -267,7 +296,7 @@ func (s *Server) deliver(rsps jmessages, ch sender, elapsed time.Duration) error
 	}
 
 	nw, err := encode(ch, rsps)
-	s.metrics.CountAndSetMax("rpc.bytesWritten", int64(nw))
+	bytesWrittenCount.Add(int64(nw))
 	return err
 }
 
@@ -322,7 +351,7 @@ func (s *Server) checkAndAssign(next jmessages) tasks {
 		if t.err != nil {
 			s.log("Request check error for %q (params %q): %v",
 				t.hreq.method, string(t.hreq.params), t.err)
-			s.metrics.Count("rpc.errors", 1)
+			rpcErrorsCount.Add(1)
 		}
 	}
 	return ts
@@ -367,19 +396,15 @@ func (s *Server) invoke(base context.Context, h Handler, req *Request) (json.Raw
 func (s *Server) ServerInfo() *ServerInfo {
 	info := &ServerInfo{
 		Methods:   []string{"*"},
+		Metrics:   make(map[string]any),
 		StartTime: s.start,
-		Counter:   make(map[string]int64),
-		MaxValue:  make(map[string]int64),
-		Label:     make(map[string]any),
 	}
+	serverMetrics.Do(func(kv expvar.KeyValue) {
+		info.Metrics[kv.Key] = json.RawMessage(kv.Value.String())
+	})
 	if n, ok := s.mux.(Namer); ok {
 		info.Methods = n.Names()
 	}
-	s.metrics.Snapshot(metrics.Snapshot{
-		Counter:  info.Counter,
-		MaxValue: info.MaxValue,
-		Label:    info.Label,
-	})
 	return info
 }
 
@@ -481,6 +506,9 @@ func (s *Server) pushReq(ctx context.Context, wantID bool, method string, params
 		}
 		s.call[id] = rsp
 		go s.waitCallback(cbctx, id, rsp)
+		rpcCallsPushed.Add(1)
+	} else {
+		rpcNotificationsPushed.Add(1)
 	}
 
 	s.log("Posting server %s %q %s", kind, method, string(bits))
@@ -489,16 +517,10 @@ func (s *Server) pushReq(ctx context.Context, wantID bool, method string, params
 		M:  method,
 		P:  bits,
 	}})
-	s.metrics.CountAndSetMax("rpc.bytesWritten", int64(nw))
-	s.metrics.Count("rpc."+kind+"sPushed", 1)
+	bytesWrittenCount.Add(int64(nw))
 	return rsp, err
 }
 
-// Metrics returns the server metrics collector for s.  If s does not define a
-// collector, this method returns nil, which is ready for use but discards all
-// metrics.
-func (s *Server) Metrics() *metrics.M { return s.metrics }
-
 // Stop shuts down the server. It is safe to call this method multiple times or
 // from concurrent goroutines; it will only take effect once.
 func (s *Server) Stop() {
@@ -597,7 +619,7 @@ func (s *Server) stop(err error) {
 
 	s.err = err
 	s.ch = nil
-	s.metrics.Count("rpc.serversActive", -1)
+	serversActiveGauge.Add(-1)
 }
 
 // read is the main receiver loop, decoding requests from the client and adding
@@ -611,11 +633,11 @@ func (s *Server) read(ch receiver) {
 		var in jmessages
 		var derr error
 		bits, err := ch.Recv()
-		s.metrics.CountAndSetMax("rpc.bytesRead", int64(len(bits)))
+		bytesReadCount.Add(int64(len(bits)))
 		if err == nil || (err == io.EOF && len(bits) != 0) {
 			err = nil
 			derr = in.parseJSON(bits)
-			s.metrics.Count("rpc.requests", int64(len(in)))
+			rpcRequestsCount.Add(int64(len(in)))
 		}
 		s.mu.Lock()
 		if err != nil { // receive failure; shut down
@@ -677,10 +699,8 @@ type ServerInfo struct {
 	// The list of method names exported by this server.
 	Methods []string `json:"methods,omitempty"`
 
-	// Metric values defined by the evaluation of methods.
-	Counter  map[string]int64 `json:"counters,omitempty"`
-	MaxValue map[string]int64 `json:"maxValue,omitempty"`
-	Label    map[string]any   `json:"labels,omitempty"`
+	// Metrics defined by the server and handler methods.
+	Metrics map[string]any `json:"metrics,omitempty"`
 
 	// When the server started.
 	StartTime time.Time `json:"startTime,omitempty"`
@@ -716,8 +736,8 @@ func (s *Server) pushError(err error) {
 		ID: json.RawMessage("null"),
 		E:  jerr,
 	}})
-	s.metrics.Count("rpc.errors", 1)
-	s.metrics.CountAndSetMax("rpc.bytesWritten", int64(nw))
+	rpcErrorsCount.Add(1)
+	bytesWrittenCount.Add(int64(nw))
 	if err != nil {
 		s.log("Writing error response: %v", err)
 	}
diff --git a/tools/examples/http/server.go b/tools/examples/http/server.go
index b570595..9890e20 100644
--- a/tools/examples/http/server.go
+++ b/tools/examples/http/server.go
@@ -21,7 +21,6 @@ import (
 	"github.com/creachadair/jrpc2"
 	"github.com/creachadair/jrpc2/handler"
 	"github.com/creachadair/jrpc2/jhttp"
-	"github.com/creachadair/jrpc2/metrics"
 )
 
 var listenAddr = flag.String("listen", "", "Service address")
@@ -37,8 +36,7 @@ func main() {
 		"Ping": handler.New(ping),
 	}, &jhttp.BridgeOptions{
 		Server: &jrpc2.ServerOptions{
-			Logger:  jrpc2.StdLogger(nil),
-			Metrics: metrics.New(),
+			Logger: jrpc2.StdLogger(nil),
 		},
 	})
 	defer bridge.Close()
diff --git a/tools/examples/server/server.go b/tools/examples/server/server.go
index 6c9c838..f0c3995 100644
--- a/tools/examples/server/server.go
+++ b/tools/examples/server/server.go
@@ -21,7 +21,6 @@ import (
 	"github.com/creachadair/jrpc2/channel"
 	"github.com/creachadair/jrpc2/code"
 	"github.com/creachadair/jrpc2/handler"
-	"github.com/creachadair/jrpc2/metrics"
 	"github.com/creachadair/jrpc2/server"
 )
 
@@ -104,7 +103,6 @@ func main() {
 		ServerOptions: &jrpc2.ServerOptions{
 			Logger:      jrpc2.StdLogger(nil),
 			Concurrency: *maxTasks,
-			Metrics:     metrics.New(),
 			AllowPush:   true,
 		},
 	})
diff --git a/tools/go.mod b/tools/go.mod
index 6a6002d..4d0b1b1 100644
--- a/tools/go.mod
+++ b/tools/go.mod
@@ -1,6 +1,6 @@
 module github.com/creachadair/jrpc2/tools
 
-go 1.17
+go 1.18
 
 require (
 	github.com/creachadair/jrpc2 v0.41.2
diff --git a/tools/go.sum b/tools/go.sum
index 965f2fc..0d40071 100644
--- a/tools/go.sum
+++ b/tools/go.sum
@@ -3,11 +3,8 @@ github.com/creachadair/jrpc2 v0.41.2/go.mod h1:nFZVfOp2tDVN0TxXvFVMwlvrrzmSyquzm
 github.com/creachadair/wschannel v0.0.0-20220916180746-961b4549de6c h1:Zwts3elkBjJdqQ+ohFWvDPUs7ge557ub76sNGRaUwiE=
 github.com/creachadair/wschannel v0.0.0-20220916180746-961b4549de6c/go.mod h1:LcRdbRkwz3Uzsh43oHwPPprbSyrxZ91f0wY2qkSPYNc=
 github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
-github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-golang.org/x/sync v0.0.0-20220907140024-f12130a52804/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=


Test patch:
diff --git a/jrpc2_test.go b/jrpc2_test.go
index d61ff8e..5317c04 100644
--- a/jrpc2_test.go
+++ b/jrpc2_test.go
@@ -6,6 +6,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"expvar"
 	"fmt"
 	"sort"
 	"sync"
@@ -541,27 +542,13 @@ func TestClient_badCallParams(t *testing.T) {
 func TestServer_serverInfoMetrics(t *testing.T) {
 	defer leaktest.Check(t)()
 
+	const metricValue = "if red were green, green would be my favourite colour"
+	customMetric := new(expvar.String)
+	customMetric.Set(metricValue)
+
 	loc := server.NewLocal(handler.Map{
 		"Metricize": handler.New(func(ctx context.Context) (bool, error) {
-			m := jrpc2.ServerFromContext(ctx).Metrics()
-			if m == nil {
-				t.Error("Request context does not contain a metrics writer")
-				return false, nil
-			}
-			m.Count("counters-written", 1)
-			m.Count("counters-written", 2)
-
-			// Max value trackers are not accumulative.
-			m.SetMaxValue("max-metric-value", 1)
-			m.SetMaxValue("max-metric-value", 5)
-			m.SetMaxValue("max-metric-value", 3)
-			m.SetMaxValue("max-metric-value", -30337)
-
-			// Counters are accumulative, and negative deltas subtract.
-			m.Count("zero-sum", 0)
-			m.Count("zero-sum", 15)
-			m.Count("zero-sum", -16)
-			m.Count("zero-sum", 1)
+			jrpc2.ServerMetrics().Set("custom-metric", customMetric)
 			return true, nil
 		}),
 	}, nil)
@@ -571,36 +558,23 @@ func TestServer_serverInfoMetrics(t *testing.T) {
 	if _, err := c.Call(ctx, "Metricize", nil); err != nil {
 		t.Fatalf("Call(Metricize) failed: %v", err)
 	}
-	if got := s.ServerInfo().Counter["rpc.serversActive"]; got != 1 {
-		t.Errorf("Metric rpc.serversActive: got %d, want 1", got)
+	active, ok := jrpc2.ServerMetrics().Get("servers_active").(*expvar.Int)
+	if !ok {
+		t.Fatal("Metric servers_active not defined")
+	} else if active.Value() != 2 { // 1 for this test, 1 for the examples
+		t.Errorf("Metric servers_active: got %d, want 1", active.Value())
 	}
 	loc.Close()
 
-	info := s.ServerInfo()
-	tests := []struct {
-		input map[string]int64
-		name  string
-		want  int64 // use < 0 to test for existence only
-	}{
-		{info.Counter, "rpc.requests", 1},
-		{info.Counter, "counters-written", 3},
-		{info.Counter, "zero-sum", 0},
-		{info.Counter, "rpc.bytesRead", -1},
-		{info.Counter, "rpc.bytesWritten", -1},
-		{info.Counter, "rpc.serversActive", 0},
-		{info.MaxValue, "max-metric-value", 5},
-		{info.MaxValue, "rpc.bytesRead", -1},
-		{info.MaxValue, "rpc.bytesWritten", -1},
-	}
-	for _, test := range tests {
-		got, ok := test.input[test.name]
-		if !ok {
-			t.Errorf("Metric %q is not defined, but was expected", test.name)
-			continue
-		}
-		if test.want >= 0 && got != test.want {
-			t.Errorf("Wrong value for metric %q: got %d, want %d", test.name, got, test.want)
-		}
+	// When populated into a ServerInfo value, metrics should be converted to
+	// JSON values in a sensible way.
+	metric := s.ServerInfo().Metrics["custom-metric"]
+	if metric == nil {
+		t.Fatal("Custom metric not found")
+	} else if got, ok := metric.(json.RawMessage); !ok {
+		t.Fatalf("Wrong metric type: got %T, want json.RawMessage", metric)
+	} else if s := string(got); s != `"`+metricValue+`"` {
+		t.Fatalf("Wrong metric value: got %q, want %q", s, metricValue)
 	}
 }
 
diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go
deleted file mode 100644
index ea85461..0000000
--- a/metrics/metrics_test.go
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2017 Michael J. Fromberger. All Rights Reserved.
-
-package metrics_test
-
-import (
-	"testing"
-
-	"github.com/creachadair/jrpc2/metrics"
-)
-
-func getCount(m *metrics.M, name string) int64 {
-	c := make(map[string]int64)
-	m.Snapshot(metrics.Snapshot{Counter: c})
-	return c[name]
-}
-
-func getMax(m *metrics.M, name string) int64 {
-	mv := make(map[string]int64)
-	m.Snapshot(metrics.Snapshot{MaxValue: mv})
-	return mv[name]
-}
-
-func getLabel(m *metrics.M, name string) any {
-	vs := make(map[string]any)
-	m.Snapshot(metrics.Snapshot{Label: vs})
-	return vs[name]
-}
-
-func TestMetrics(t *testing.T) {
-	m := metrics.New()
-	wantCount := func(name string, want int64) {
-		t.Helper()
-		got := getCount(m, name)
-		if got != want {
-			t.Errorf("Counter %q: got %d, want %d", name, got, want)
-		}
-	}
-	wantMax := func(name string, want int64) {
-		t.Helper()
-		got := getMax(m, name)
-		if got != want {
-			t.Errorf("MaxValue %q: got %d, want %d", name, got, want)
-		}
-	}
-	wantLabel := func(name string, want any) {
-		t.Helper()
-		got := getLabel(m, name)
-		if got != want {
-			t.Errorf("Label %q: got %v, want %v", name, got, want)
-		}
-	}
-
-	wantCount("foo", 0)
-	m.Count("foo", 1)
-	wantCount("foo", 1)
-	m.Count("foo", 4)
-	wantCount("foo", 5)
-	m.Count("foo", -3)
-	wantCount("foo", 2)
-
-	wantMax("max", 0)
-	m.SetMaxValue("max", 10)
-	wantMax("max", 10)
-	m.SetMaxValue("max", 5)
-	wantMax("max", 10)
-	m.SetMaxValue("max", 12)
-	wantMax("max", 12)
-
-	m.CountAndSetMax("bar", 1)
-	wantCount("bar", 1)
-	wantMax("bar", 1)
-	m.CountAndSetMax("bar", 4)
-	wantCount("bar", 5)
-	wantMax("bar", 4)
-	m.CountAndSetMax("bar", -3)
-	wantCount("bar", 2)
-	wantMax("bar", 4)
-	m.CountAndSetMax("bar", 3)
-	wantCount("bar", 5)
-	wantMax("bar", 4)
-
-	wantLabel("hey", nil)
-	m.SetLabel("hey", "you")
-	wantLabel("hey", "you")
-	m.SetLabel("hey", nil)
-	wantLabel("hey", nil)
-
-	var numCalls int
-	m.SetLabel("dyno", func() any {
-		numCalls++
-		return numCalls
-	})
-	wantLabel("dyno", 1)
-	wantLabel("dyno", 2)
-	wantLabel("dyno", 3)
-	m.SetLabel("dyno", nil)
-	wantLabel("dyno", nil)
-
-	wantLabel("quux", nil)
-	m.EditLabel("quux", func(v any) any {
-		return "x"
-	})
-	wantLabel("quux", "x")
-	m.EditLabel("quux", func(v any) any {
-		return v.(string) + "2"
-	})
-	wantLabel("quux", "x2")
-}
diff --git a/server/local_test.go b/server/local_test.go
index c34788a..ee07745 100644
--- a/server/local_test.go
+++ b/server/local_test.go
@@ -37,8 +37,10 @@ func TestLocal(t *testing.T) {
 	}
 
 	// A couple coherence checks on the server info.
-	if nr := si.Counter["rpc.requests"]; nr != 1 {
-		t.Errorf("rpc.serverInfo reports %d requests, wanted 1", nr)
+	if nr, ok := si.Metrics["rpc_requests"].(float64); !ok {
+		t.Fatalf("rpc.serverInfo does not have rpc_requests: %[1]T %[1]v", si.Metrics["rpc_requests"])
+	} else if nr <= 0 {
+		t.Errorf("rpc.serverInfo reports %v requests, wanted 1 or more", nr)
 	}
 	if len(si.Methods) != 0 {
 		t.Errorf("rpc.serverInfo reports methods %+q, wanted []", si.Methods)