metric-api: Add prometheus metrics

This commit is contained in:
Juvenn Woo 2024-08-24 12:47:08 +08:00
parent 05c4f94982
commit 08e72b12da
20 changed files with 294 additions and 40 deletions

View File

@ -13,7 +13,7 @@ RUN set -ex \
&& export COMMIT=$(git rev-parse --short HEAD) \
&& export VERSION=$(go run ./cmd/internal/read_tag) \
&& go build -v -trimpath -tags \
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_ech,with_utls,with_reality_server,with_acme,with_clash_api" \
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_ech,with_utls,with_reality_server,with_acme,with_clash_api,with_metric_api" \
-o /go/bin/sing-box \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -s -w -buildid= -checklinkname=0" \
./cmd/sing-box

View File

@ -1,6 +1,6 @@
NAME = sing-box
COMMIT = $(shell git rev-parse --short HEAD)
TAGS_GO120 = with_gvisor,with_dhcp,with_wireguard,with_reality_server,with_clash_api,with_quic,with_utls
TAGS_GO120 = with_gvisor,with_dhcp,with_wireguard,with_reality_server,with_clash_api,with_metric_api,with_quic,with_utls
TAGS_GO121 = with_ech
TAGS ?= $(TAGS_GO118),$(TAGS_GO120),$(TAGS_GO121)
TAGS_TEST ?= with_gvisor,with_quic,with_wireguard,with_grpc,with_ech,with_utls,with_reality_server

View File

@ -118,10 +118,20 @@ func OutboundTag(detour Outbound) string {
type V2RayServer interface {
Service
StatsService() V2RayStatsService
StatsService() PacketTracking
}
type V2RayStatsService interface {
RoutedConnection(inbound string, outbound string, user string, conn net.Conn) net.Conn
RoutedPacketConnection(inbound string, outbound string, user string, conn N.PacketConn) N.PacketConn
type MetricService interface {
Service
PacketTracking
}
type PacketTracking interface {
WithConnCounters(inbound, outbound, user string) ConnAdapter[net.Conn]
WithPacketConnCounters(inbound, outbound, user string) ConnAdapter[N.PacketConn]
}
// ConnAdapter
// Transform a connection to another connection. The T should be either of
// net.Conn or N.PacketConn.
type ConnAdapter[T any] func(T) T

12
box.go
View File

@ -61,6 +61,7 @@ func New(options Options) (*Box, error) {
var needCacheFile bool
var needClashAPI bool
var needV2RayAPI bool
var needMetricAPI bool
if experimentalOptions.CacheFile != nil && experimentalOptions.CacheFile.Enabled || options.PlatformLogWriter != nil {
needCacheFile = true
}
@ -70,6 +71,9 @@ func New(options Options) (*Box, error) {
if experimentalOptions.V2RayAPI != nil && experimentalOptions.V2RayAPI.Listen != "" {
needV2RayAPI = true
}
if experimentalOptions.MetricAPI.Enabled() {
needMetricAPI = true
}
var defaultLogWriter io.Writer
if options.PlatformInterface != nil {
defaultLogWriter = io.Discard
@ -183,6 +187,14 @@ func New(options Options) (*Box, error) {
router.SetV2RayServer(v2rayServer)
preServices2["v2ray api"] = v2rayServer
}
if needMetricAPI {
metricServer, err := experimental.NewMetricServer(logFactory.NewLogger("metric-api"), common.PtrValueOrDefault(experimentalOptions.MetricAPI))
if err != nil {
return nil, E.Cause(err, "create metric api server")
}
router.SetMetricServer(metricServer)
preServices2["metric api"] = metricServer
}
return &Box{
router: router,
inbounds: inbounds,

View File

@ -54,7 +54,7 @@ func init() {
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -s -w -buildid=")
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag)
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_ech", "with_utls", "with_clash_api")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_ech", "with_utls", "with_clash_api", "with_metric_api")
iosTags = append(iosTags, "with_dhcp", "with_low_memory", "with_conntrack")
debugTags = append(debugTags, "debug")
}

View File

@ -0,0 +1,19 @@
### Structure
```json
{
"listen": ":8080",
"path": "/metrics"
}
```
### Fields
#### listen
Prometheus metrics API listening address, disabled if empty.
#### path
Prometheus scrape path, `/metrics` will be used if empty.

View File

@ -0,0 +1,19 @@
### Structure
```json
{
"listen": ":8080",
"path": "/metrics"
}
```
### Fields
#### listen
Prometheus 指标监听地址,如果为空则禁用。
#### path
HTTP 路径,如果为空则使用 `/metrics`。本路径可用于 Prometheus exporter 进行抓取。

View File

@ -65,6 +65,7 @@ go build -tags "tag_a tag_b" ./cmd/sing-box
| `with_reality_server` | :material-check: | Build with reality TLS server support, see [TLS](/configuration/shared/tls/). |
| `with_acme` | :material-check: | Build with ACME TLS certificate issuer support, see [TLS](/configuration/shared/tls/). |
| `with_clash_api` | :material-check: | Build with Clash API support, see [Experimental](/configuration/experimental#clash-api-fields). |
| `with_metric_api` | :material-check: | Build with Prometheus metric API support, see [Experimental](/configuration/experimental#metric-api-fields). |
| `with_v2ray_api` | :material-close: | Build with V2Ray API support, see [Experimental](/configuration/experimental#v2ray-api-fields). |
| `with_gvisor` | :material-check: | Build with gVisor support, see [Tun inbound](/configuration/inbound/tun#stack) and [WireGuard outbound](/configuration/outbound/wireguard#system_interface). |
| `with_embedded_tor` (CGO required) | :material-close: | Build with embedded Tor support, see [Tor outbound](/configuration/outbound/tor/). |

View File

@ -65,6 +65,7 @@ go build -tags "tag_a tag_b" ./cmd/sing-box
| `with_reality_server` | :material-check: | Build with reality TLS server support, see [TLS](/configuration/shared/tls/). |
| `with_acme` | :material-check: | Build with ACME TLS certificate issuer support, see [TLS](/configuration/shared/tls/). |
| `with_clash_api` | :material-check: | Build with Clash API support, see [Experimental](/configuration/experimental#clash-api-fields). |
| `with_metric_api` | :material-check: | Build with Prometheus metric API support, see [Experimental](/configuration/experimental#metric-api-fields). |
| `with_v2ray_api` | :material-close: | Build with V2Ray API support, see [Experimental](/configuration/experimental#v2ray-api-fields). |
| `with_gvisor` | :material-check: | Build with gVisor support, see [Tun inbound](/configuration/inbound/tun#stack) and [WireGuard outbound](/configuration/outbound/wireguard#system_interface). |
| `with_embedded_tor` (CGO required) | :material-close: | Build with embedded Tor support, see [Tor outbound](/configuration/outbound/tor/). |

24
experimental/metricapi.go Normal file
View File

@ -0,0 +1,24 @@
package experimental
import (
"os"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
)
type MetricServerConstructor = func(logger log.Logger, options option.MetricOptions) (adapter.MetricService, error)
var metricServerConstructor MetricServerConstructor
func RegisterMetricServerConstructor(constructor MetricServerConstructor) {
metricServerConstructor = constructor
}
func NewMetricServer(logger log.Logger, options option.MetricOptions) (adapter.MetricService, error) {
if metricServerConstructor == nil {
return nil, os.ErrInvalid
}
return metricServerConstructor(logger, options)
}

View File

@ -0,0 +1,72 @@
package metrics
import (
"net/http"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/experimental"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var _ adapter.MetricService = (*metricServer)(nil)
func init() {
experimental.RegisterMetricServerConstructor(NewServer)
}
type metricServer struct {
http *http.Server
logger log.Logger
opts option.MetricOptions
packetCountersInbound *prometheus.CounterVec
packetCountersOutbound *prometheus.CounterVec
}
func NewServer(logger log.Logger, opts option.MetricOptions) (adapter.MetricService, error) {
r := chi.NewRouter()
_server := &http.Server{
Addr: opts.Listen,
Handler: r,
}
if opts.Path == "" {
opts.Path = "/metrics"
}
r.Get(opts.Path, promhttp.Handler().ServeHTTP)
server := &metricServer{
http: _server,
logger: logger,
opts: opts,
}
err := server.registerMetrics()
return server, err
}
func (s *metricServer) Start() error {
if !s.opts.Enabled() {
return nil
}
go func() {
err := s.http.ListenAndServe()
if err != nil {
s.logger.Error("metrics api listen error", err)
} else {
s.logger.Info("metrics api listening at ", s.http.Addr, s.opts.Path)
}
}()
return nil
}
func (s *metricServer) Close() error {
if !s.opts.Enabled() {
return nil
}
return common.Close(common.PtrOrNil(s.http))
}

View File

@ -0,0 +1,52 @@
package metrics
import (
"net"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/bufio"
N "github.com/sagernet/sing/common/network"
"github.com/prometheus/client_golang/prometheus"
)
func (s *metricServer) registerMetrics() error {
s.packetCountersInbound = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "inbound_packet_bytes",
Help: "Total bytes of inbound packets",
}, []string{"inbound", "user"})
s.packetCountersOutbound = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "outbound_packet_bytes",
Help: "Total bytes of outbound packets",
}, []string{"outbound", "user"})
var err error
err = prometheus.Register(s.packetCountersInbound)
err = prometheus.Register(s.packetCountersOutbound)
return err
}
func (s *metricServer) WithConnCounters(inbound, outbound, user string) adapter.ConnAdapter[net.Conn] {
incRead, incWrite := s.getPacketCounters(inbound, outbound, user)
return func(conn net.Conn) net.Conn {
return bufio.NewCounterConn(conn, []N.CountFunc{incRead}, []N.CountFunc{incWrite})
}
}
func (s *metricServer) WithPacketConnCounters(inbound, outbound, user string) adapter.ConnAdapter[N.PacketConn] {
incRead, incWrite := s.getPacketCounters(inbound, outbound, user)
return func(conn N.PacketConn) N.PacketConn {
return bufio.NewCounterPacketConn(conn, []N.CountFunc{incRead}, []N.CountFunc{incWrite})
}
}
func (s *metricServer) getPacketCounters(inbound, outbound, user string) (
readCounters N.CountFunc,
writeCounters N.CountFunc,
) {
return func(n int64) {
s.packetCountersInbound.WithLabelValues(inbound, user).Add(float64(n))
}, func(n int64) {
s.packetCountersOutbound.WithLabelValues(outbound, user).Add(float64(n))
}
}

View File

@ -70,6 +70,6 @@ func (s *Server) Close() error {
)
}
func (s *Server) StatsService() adapter.V2RayStatsService {
func (s *Server) StatsService() adapter.PacketTracking {
return s.statsService
}

View File

@ -22,7 +22,7 @@ func init() {
}
var (
_ adapter.V2RayStatsService = (*StatsService)(nil)
_ adapter.PacketTracking = (*StatsService)(nil)
_ StatsServiceServer = (*StatsService)(nil)
)
@ -60,14 +60,14 @@ func NewStatsService(options option.V2RayStatsServiceOptions) *StatsService {
}
}
func (s *StatsService) RoutedConnection(inbound string, outbound string, user string, conn net.Conn) net.Conn {
func (s *StatsService) getPacketCounters(inbound, outbound, user string) (incRead N.CountFunc, inWrite N.CountFunc) {
var readCounter []*atomic.Int64
var writeCounter []*atomic.Int64
countInbound := inbound != "" && s.inbounds[inbound]
countOutbound := outbound != "" && s.outbounds[outbound]
countUser := user != "" && s.users[user]
if !countInbound && !countOutbound && !countUser {
return conn
return func(n int64) {}, func(n int64) {}
}
s.access.Lock()
if countInbound {
@ -83,33 +83,29 @@ func (s *StatsService) RoutedConnection(inbound string, outbound string, user st
writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink"))
}
s.access.Unlock()
return bufio.NewInt64CounterConn(conn, readCounter, writeCounter)
return func(n int64) {
for _, c := range readCounter {
c.Add(n)
}
}, func(n int64) {
for _, c := range writeCounter {
c.Add(n)
}
}
}
func (s *StatsService) RoutedPacketConnection(inbound string, outbound string, user string, conn N.PacketConn) N.PacketConn {
var readCounter []*atomic.Int64
var writeCounter []*atomic.Int64
countInbound := inbound != "" && s.inbounds[inbound]
countOutbound := outbound != "" && s.outbounds[outbound]
countUser := user != "" && s.users[user]
if !countInbound && !countOutbound && !countUser {
return conn
func (s *StatsService) WithConnCounters(inbound, outbound, user string) adapter.ConnAdapter[net.Conn] {
rd, wr := s.getPacketCounters(inbound, outbound, user)
return func(conn net.Conn) net.Conn {
return bufio.NewCounterConn(conn, []N.CountFunc{rd}, []N.CountFunc{wr})
}
s.access.Lock()
if countInbound {
readCounter = append(readCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink"))
writeCounter = append(writeCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink"))
}
if countOutbound {
readCounter = append(readCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink"))
writeCounter = append(writeCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink"))
func (s *StatsService) WithPacketConnCounters(inbound, outbound, user string) adapter.ConnAdapter[N.PacketConn] {
rd, wr := s.getPacketCounters(inbound, outbound, user)
return func(conn N.PacketConn) N.PacketConn {
return bufio.NewCounterPacketConn(conn, []N.CountFunc{rd}, []N.CountFunc{wr})
}
if countUser {
readCounter = append(readCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>uplink"))
writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink"))
}
s.access.Unlock()
return bufio.NewInt64CounterPacketConn(conn, readCounter, writeCounter)
}
func (s *StatsService) GetStats(ctx context.Context, request *GetStatsRequest) (*GetStatsResponse, error) {

5
go.mod
View File

@ -50,7 +50,7 @@ require (
golang.org/x/sys v0.22.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
google.golang.org/grpc v1.63.2
google.golang.org/protobuf v1.33.0
google.golang.org/protobuf v1.34.2
howett.net/plist v1.0.1
)
@ -72,7 +72,7 @@ require (
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/libdns/libdns v0.2.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
@ -81,6 +81,7 @@ require (
github.com/onsi/ginkgo/v2 v2.9.7 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.2 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect

4
go.sum
View File

@ -53,6 +53,7 @@ github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtL
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@ -91,6 +92,8 @@ github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg=
github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs=
@ -213,6 +216,7 @@ google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

5
include/metricapi.go Normal file
View File

@ -0,0 +1,5 @@
//go:build with_metric_api
package include
import _ "github.com/sagernet/sing-box/experimental/v2rayapi"

17
include/metricapi_stub.go Normal file
View File

@ -0,0 +1,17 @@
//go:build !with_metric_api
package include
import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/experimental"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
)
func init() {
experimental.RegisterMetricServerConstructor(func(logger log.Logger, options option.MetricOptions) (adapter.MetricService, error) {
return nil, E.New(`metric api is not included in this build, rebuild with -tags with_metric_api`)
})
}

View File

@ -5,6 +5,7 @@ type ExperimentalOptions struct {
ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"`
V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"`
Debug *DebugOptions `json:"debug,omitempty"`
MetricAPI *MetricOptions `json:"metrics,omitempty"`
}
type CacheFileOptions struct {
@ -50,3 +51,12 @@ type V2RayStatsServiceOptions struct {
Outbounds []string `json:"outbounds,omitempty"`
Users []string `json:"users,omitempty"`
}
type MetricOptions struct {
Listen string `json:"listen,omitempty"`
Path string `json:"path,omitempty"`
}
func (m *MetricOptions) Enabled() bool {
return m != nil && m.Listen != ""
}

View File

@ -93,6 +93,7 @@ type Router struct {
pauseManager pause.Manager
clashServer adapter.ClashServer
v2rayServer adapter.V2RayServer
metricServer adapter.MetricService
platformInterface platform.Interface
needWIFIState bool
needPackageManager bool
@ -921,9 +922,12 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad
}
if r.v2rayServer != nil {
if statsService := r.v2rayServer.StatsService(); statsService != nil {
conn = statsService.RoutedConnection(metadata.Inbound, detour.Tag(), metadata.User, conn)
conn = statsService.WithConnCounters(metadata.Inbound, detour.Tag(), metadata.User)(conn)
}
}
if r.metricServer != nil {
conn = r.metricServer.WithConnCounters(metadata.Inbound, detour.Tag(), metadata.User)(conn)
}
return detour.NewConnection(ctx, conn, metadata)
}
@ -1097,9 +1101,12 @@ func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, m
}
if r.v2rayServer != nil {
if statsService := r.v2rayServer.StatsService(); statsService != nil {
conn = statsService.RoutedPacketConnection(metadata.Inbound, detour.Tag(), metadata.User, conn)
conn = statsService.WithPacketConnCounters(metadata.Inbound, detour.Tag(), metadata.User)(conn)
}
}
if r.metricServer != nil {
conn = r.metricServer.WithPacketConnCounters(metadata.Inbound, detour.Tag(), metadata.User)(conn)
}
if metadata.FakeIP {
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination)
}
@ -1265,6 +1272,10 @@ func (r *Router) SetV2RayServer(server adapter.V2RayServer) {
r.v2rayServer = server
}
func (r *Router) SetMetricServer(server adapter.MetricService) {
r.metricServer = server
}
func (r *Router) OnPackagesUpdated(packages int, sharedUsers int) {
r.logger.Info("updated packages list: ", packages, " packages, ", sharedUsers, " shared users")
}