port over code from monero-operator

the code has been initially written under `monero-operator`, but it
turns out that having the exporter available for non-kubernetes users is
pretty good, thus, making it its own separate thing.

Signed-off-by: Ciro S. Costa <utxobr@protonmail.com>
This commit is contained in:
Ciro S. Costa 2021-06-26 10:54:14 -04:00
parent 72891bcce9
commit 31f5a6f84b
13 changed files with 2105 additions and 0 deletions

23
.github/workflows/go.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Go
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.16
- name: Build
run: make install
- name: Test
run: make test
- name: Lint
run: make lint

62
.golangci.yaml Normal file
View File

@ -0,0 +1,62 @@
linters:
enable:
- asciicheck
- bodyclose
- cyclop
- deadcode
- dogsled
- dupl
- durationcheck
- errcheck
- errorlint
- exhaustive
- exportloopref
- forbidigo
- forcetypeassert
- funlen
- gocognit
- goconst
- gocritic
- gocyclo
- godot
- godox
- goerr113
- goimports
- gosec
- govet
- ifshort
- ineffassign
- lll
- makezero
- misspell
- nakedret
- nestif
- nilerr
- nlreturn
- noctx
- nolintlint
- paralleltest
- prealloc
- predeclared
- revive
- staticcheck
- structcheck
- tagliatelle
- testpackage
- typecheck
- unconvert
- unparam
- unused
- varcheck
- wastedassign
- whitespace
- wrapcheck
linters-settings:
goimports:
local-prefixes: github.com/cirocosta/monero-exporter
exhaustive:
default-signifies-exhaustive: true
run:
timeout: 5m

8
Makefile Normal file
View File

@ -0,0 +1,8 @@
install:
go install -v ./cmd/monero-exporter
test:
go test ./...
lint:
go run github.com/golangci/golangci-lint/cmd/golangci-lint run --config=.golangci.yaml

View File

@ -0,0 +1,80 @@
package main
import (
"context"
"fmt"
"net"
"os"
"github.com/cirocosta/go-monero/pkg/rpc"
"github.com/jessevdk/go-flags"
"github.com/oschwald/geoip2-golang"
"github.com/cirocosta/monero-exporter/pkg/collector"
"github.com/cirocosta/monero-exporter/pkg/exporter"
)
type MetricsCommand struct {
// nolint:lll
MonerodAddress string `long:"monerod-address" default:"http://localhost:18081" required:"true" description:"address of monerod rpc (restricted if possible)"`
GeoIPFile string `long:"geoip-file" description:"filepath of geoip database"`
}
func main() {
cmd := &MetricsCommand{}
if _, err := flags.Parse(cmd); err != nil {
os.Exit(1)
}
if err := cmd.Execute(nil); err != nil {
panic(err)
}
}
func (c *MetricsCommand) Execute(_ []string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
prometheusExporter, err := exporter.New()
if err != nil {
return fmt.Errorf("new exporter: %w", err)
}
defer prometheusExporter.Close()
daemonClient, err := rpc.NewClient(c.MonerodAddress)
if err != nil {
return fmt.Errorf("new client '%s': %w", c.MonerodAddress, err)
}
collectorOpts := []collector.Option{}
if c.GeoIPFile != "" {
db, err := geoip2.Open(c.GeoIPFile)
if err != nil {
return fmt.Errorf("geoip open: %w", err)
}
defer db.Close()
countryMapper := func(ip net.IP) (string, error) {
res, err := db.Country(ip)
if err != nil {
return "", fmt.Errorf("country '%s': %w", ip, err)
}
return res.RegisteredCountry.IsoCode, nil
}
collectorOpts = append(collectorOpts, collector.WithCountryMapper(countryMapper))
}
if err := collector.Register(daemonClient, collectorOpts...); err != nil {
return fmt.Errorf("collector register: %w", err)
}
if err := prometheusExporter.Run(ctx); err != nil {
return fmt.Errorf("prometheus exporter run: %w", err)
}
return nil
}

16
go.mod Normal file
View File

@ -0,0 +1,16 @@
module github.com/cirocosta/monero-exporter
go 1.16
require (
github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b
github.com/cirocosta/go-monero v0.0.0-20210613220451-207bd1f632c0
github.com/go-logr/logr v0.4.0
github.com/go-logr/zapr v0.4.0
github.com/golangci/golangci-lint v1.41.1 // indirect
github.com/jessevdk/go-flags v1.5.0
github.com/oschwald/geoip2-golang v1.5.0
github.com/prometheus/client_golang v1.11.0
go.uber.org/zap v1.17.0
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
)

1102
go.sum Normal file

File diff suppressed because it is too large Load Diff

23
hack/table-printer.awk Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/awk -f
# gh-table-printer - prints to `stdout` the metric descriptions
# in GitHub flavored markdown tables[1].
#
# [1] - https://help.github.com/articles/organizing-information-with-tables/
#
# Usage: `curl -s localhost:9100/metrics | grep container_ | ./gh-table-printer`
BEGIN {
print "| name | description |"
print "| ---- | ----------- |"
}
/HELP/ {
line="| " $3 " |"
for (i = 4; i <= NF; i++) {
line = line " "$i
}
line = line " |"
print line
}

640
pkg/collector/collector.go Normal file
View File

@ -0,0 +1,640 @@
package collector
import (
"context"
"fmt"
"math"
"net"
"reflect"
"strconv"
"time"
"github.com/bmizerany/perks/quantile"
"github.com/cirocosta/go-monero/pkg/rpc"
"github.com/go-logr/logr"
"github.com/go-logr/zapr"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
// CountryMapper defines the signature of a function that given an IP,
// translates it into a country name.
//
// f(ip) -> CN
//
type CountryMapper func(net.IP) (string, error)
// Collector implements the prometheus Collector interface, providing monero
// metrics whenever a prometheus scrape is received.
//
type Collector struct {
// client is a Go client that communicated with a `monero` daemon via
// plain HTTP(S) RPC.
//
client *rpc.Client
// countryMapper is a function that knows how to translate IPs to
// country codes.
//
// optional: if nil, no country-mapping will take place.
//
countryMapper CountryMapper
log logr.Logger
}
// ensure that we implement prometheus' collector interface.
//
var _ prometheus.Collector = &Collector{}
// Option is a type used by functional arguments to mutate the collector to
// override default behavior.
//
type Option func(c *Collector)
// WithCountryMapper is a functional argument that overrides the default no-op
// country mapper.
//
func WithCountryMapper(v CountryMapper) func(c *Collector) {
return func(c *Collector) {
c.countryMapper = v
}
}
// Register registers this collector with the global prometheus collectors
// registry making it available for an exporter to collect our metrics.
//
func Register(client *rpc.Client, opts ...Option) error {
defaultLogger, err := zap.NewDevelopment()
if err != nil {
return fmt.Errorf("zap new development: %w", err)
}
c := &Collector{
client: client,
log: zapr.NewLogger(defaultLogger.Named("collector")),
countryMapper: func(_ net.IP) (string, error) { return "lol", nil },
}
for _, opt := range opts {
opt(c)
}
if err := prometheus.Register(c); err != nil {
return fmt.Errorf("register: %w", err)
}
return nil
}
// CollectFunc defines a standardized signature for functions that want to
// expose metrics for collection.
//
type CollectFunc func(ctx context.Context, ch chan<- prometheus.Metric) error
// Describe implements the Describe function of the Collector interface.
//
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
// Because we can present the description of the metrics at collection time, we
// don't need to write anything to the channel.
}
// Collect implements the Collect function of the Collector interface.
//
// Here is where all of the calls to a monero rpc endpoint is made, each being
// wrapped in its own function, all being called concurrently.
//
func (c *Collector) Collect(ch chan<- prometheus.Metric) {
var g *errgroup.Group
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
g, ctx = errgroup.WithContext(ctx)
for _, collector := range []struct {
name string
fn CollectFunc
}{
{"info_stats", c.CollectInfoStats},
{"mempool_stats", c.CollectMempoolStats},
{"last_block_header", c.CollectLastBlockHeader},
{"bans", c.CollectBans},
{"peer_height_divergence", c.CollectPeerHeightDivergence},
{"fee_estimate", c.CollectFeeEstimate},
{"peers", c.CollectPeers},
{"connections", c.CollectConnections},
{"last_block_stats", c.CollectLastBlockStats},
{"peers_live_time", c.CollectPeersLiveTime},
{"net_stats", c.CollectNetStats},
{"collect_rpc", c.CollectRPC},
} {
collector := collector
g.Go(func() error {
if err := collector.fn(ctx, ch); err != nil {
return fmt.Errorf("collector fn '%s': %w", collector.name, err)
}
return nil
})
}
if err := g.Wait(); err != nil {
c.log.Error(err, "wait")
}
}
// CollectConnections.
//
func (c *Collector) CollectConnections(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetConnections(ctx)
if err != nil {
return fmt.Errorf("get connections: %w", err)
}
perCountryCounter := map[string]uint64{}
for _, conn := range res.Connections {
country, err := c.countryMapper(net.ParseIP(conn.Host))
if err != nil {
return fmt.Errorf("to country '%s': %w", conn.Host, err)
}
perCountryCounter[country]++
}
desc := prometheus.NewDesc(
"monero_connections",
"connections info",
[]string{"country"}, nil,
)
for country, count := range perCountryCounter {
ch <- prometheus.MustNewConstMetric(
desc,
prometheus.GaugeValue,
float64(count),
country,
)
}
return nil
}
// CollectPeers.
//
func (c *Collector) CollectPeers(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetPeerList(ctx)
if err != nil {
return fmt.Errorf("get peer list: %w", err)
}
perCountryCounter := map[string]uint64{}
for _, peer := range res.WhiteList {
country, err := c.countryMapper(net.ParseIP(peer.Host))
if err != nil {
return fmt.Errorf("to country '%s': %w", peer.Host, err)
}
perCountryCounter[country]++
}
desc := prometheus.NewDesc(
"monero_peers_new",
"peers info",
[]string{"country"}, nil,
)
for country, count := range perCountryCounter {
ch <- prometheus.MustNewConstMetric(
desc,
prometheus.GaugeValue,
float64(count),
country,
)
}
return nil
}
// CollectLastBlockHeader.
//
func (c *Collector) CollectLastBlockHeader(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetLastBlockHeader(ctx)
if err != nil {
return fmt.Errorf("get last block header: %w", err)
}
metrics, err := c.toMetrics("last_block_header", &res.BlockHeader)
if err != nil {
return fmt.Errorf("to metrics: %w", err)
}
for _, metric := range metrics {
ch <- metric
}
return nil
}
// CollectInfoStats.
//
func (c *Collector) CollectInfoStats(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetInfo(ctx)
if err != nil {
return fmt.Errorf("get transaction pool: %w", err)
}
metrics, err := c.toMetrics("info", res)
if err != nil {
return fmt.Errorf("to metrics: %w", err)
}
for _, metric := range metrics {
ch <- metric
}
return nil
}
func (c *Collector) CollectLastBlockStats(ctx context.Context, ch chan<- prometheus.Metric) error {
lastBlockHeaderResp, err := c.client.GetLastBlockHeader(ctx)
if err != nil {
return fmt.Errorf("get last block header: %w", err)
}
currentHeight := lastBlockHeaderResp.BlockHeader.Height
block, err := c.client.GetBlock(ctx, rpc.GetBlockRequestParameters{
Height: &currentHeight,
})
if err != nil {
return fmt.Errorf("get block '%d': %w", currentHeight, err)
}
blockJSON, err := block.InnerJSON()
if err != nil {
return fmt.Errorf("block inner json: %w", err)
}
txnsResp, err := c.client.GetTransactions(ctx, blockJSON.TxHashes)
if err != nil {
return fmt.Errorf("get txns: %w", err)
}
txns, err := txnsResp.GetTransactions()
if err != nil {
return fmt.Errorf("get transactions: %w", err)
}
phis := []float64{0.25, 0.50, 0.75, 0.90, 0.95, 0.99, 1}
var (
streamTxnSize = quantile.NewTargeted(phis...)
sumTxnSize = float64(0)
quantilesTxnSize = make(map[float64]float64, len(phis))
streamTxnFee = quantile.NewTargeted(phis...)
sumTxnFee = float64(0)
quantilesTxnFee = make(map[float64]float64, len(phis))
streamVin = quantile.NewTargeted(phis...)
sumVin = float64(0)
quantilesVin = make(map[float64]float64, len(phis))
streamVout = quantile.NewTargeted(phis...)
sumVout = float64(0)
quantilesVout = make(map[float64]float64, len(phis))
)
for _, txn := range txnsResp.TxsAsHex {
streamTxnSize.Insert(float64(len(txn)))
sumTxnSize += float64(len(txn))
}
for _, txn := range txns {
streamTxnFee.Insert(float64(txn.RctSignatures.Txnfee))
sumTxnFee += float64(txn.RctSignatures.Txnfee)
streamVin.Insert(float64(len(txn.Vin)))
sumVin += float64(len(txn.Vin))
streamVout.Insert(float64(len(txn.Vout)))
sumVout += float64(len(txn.Vout))
}
for _, phi := range phis {
quantilesTxnSize[phi] = streamTxnSize.Query(phi)
quantilesTxnFee[phi] = streamTxnFee.Query(phi)
quantilesVin[phi] = streamVin.Query(phi)
quantilesVout[phi] = streamVout.Query(phi)
}
ch <- prometheus.MustNewConstSummary(
prometheus.NewDesc(
"monero_last_block_txn_size",
"distribution of tx sizes",
nil, nil,
),
uint64(streamTxnSize.Count()),
sumTxnSize,
quantilesTxnSize,
)
ch <- prometheus.MustNewConstSummary(
prometheus.NewDesc(
"monero_last_block_txn_fee",
"distribution of outputs in last block",
nil, nil,
),
uint64(streamTxnFee.Count()),
sumTxnFee,
quantilesTxnFee,
)
ch <- prometheus.MustNewConstSummary(
prometheus.NewDesc(
"monero_last_block_vin",
"distribution of inputs in last block",
nil, nil,
),
uint64(streamVin.Count()),
sumVin,
quantilesVin,
)
ch <- prometheus.MustNewConstSummary(
prometheus.NewDesc(
"monero_last_block_vout",
"distribution of outputs in last block",
nil, nil,
),
uint64(streamVout.Count()),
sumVout,
quantilesVout,
)
return nil
}
func (c *Collector) CollectPeerHeightDivergence(ctx context.Context, ch chan<- prometheus.Metric) error {
blockCountRes, err := c.client.GetBlockCount(ctx)
if err != nil {
return fmt.Errorf("get block count: %w", err)
}
res, err := c.client.GetConnections(ctx)
if err != nil {
return fmt.Errorf("get connections: %w", err)
}
phis := []float64{0.25, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 0.99}
stream := quantile.NewTargeted(phis...)
sum := float64(0)
ourHeight := blockCountRes.Count
for _, conn := range res.Connections {
diff := math.Abs(float64(ourHeight - uint64(conn.Height)))
stream.Insert(diff)
sum += diff
}
quantiles := make(map[float64]float64, len(phis))
for _, phi := range phis {
quantiles[phi] = stream.Query(phi)
}
desc := prometheus.NewDesc(
"monero_height_divergence",
"how much our peers diverge from us in block height",
nil, nil,
)
ch <- prometheus.MustNewConstSummary(
desc,
uint64(stream.Count()),
sum,
quantiles,
)
return nil
}
func (c *Collector) CollectPeersLiveTime(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetConnections(ctx)
if err != nil {
return fmt.Errorf("get connections: %w", err)
}
var (
phis = []float64{0.25, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 0.99}
sum = float64(0)
stream = quantile.NewTargeted(phis...)
quantiles = make(map[float64]float64, len(phis))
)
for _, conn := range res.Connections {
stream.Insert(float64(conn.LiveTime))
sum += float64(conn.LiveTime)
}
for _, phi := range phis {
quantiles[phi] = stream.Query(phi)
}
desc := prometheus.NewDesc(
"monero_connections_livetime",
"peers livetime distribution",
nil, nil,
)
ch <- prometheus.MustNewConstSummary(
desc,
uint64(stream.Count()),
sum,
quantiles,
)
return nil
}
func (c *Collector) CollectNetStats(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetNetStats(ctx)
if err != nil {
return fmt.Errorf("get fee estimate: %w", err)
}
ch <- prometheus.MustNewConstMetric(
prometheus.NewDesc(
"monero_net_total_in_bytes",
"network statistics",
nil, nil,
),
prometheus.CounterValue,
float64(res.TotalBytesIn),
)
ch <- prometheus.MustNewConstMetric(
prometheus.NewDesc(
"monero_net_total_out_bytes",
"network statistics",
nil, nil,
),
prometheus.CounterValue,
float64(res.TotalBytesOut),
)
return nil
}
func (c *Collector) CollectFeeEstimate(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetFeeEstimate(ctx, 1)
if err != nil {
return fmt.Errorf("get fee estimate: %w", err)
}
desc := prometheus.NewDesc(
"monero_fee_estimate",
"fee estimate for 1 grace block",
nil, nil,
)
ch <- prometheus.MustNewConstMetric(
desc,
prometheus.GaugeValue,
float64(res.Fee),
)
return nil
}
func (c *Collector) CollectRPC(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.RPCAccessTracking(ctx)
if err != nil {
return fmt.Errorf("rpc access tracking: %w", err)
}
descCount := prometheus.NewDesc(
"monero_rpc_count",
"todo",
[]string{"method"}, nil,
)
descTime := prometheus.NewDesc(
"monero_rpc_time",
"todo",
[]string{"method"}, nil,
)
for _, d := range res.Data {
ch <- prometheus.MustNewConstMetric(
descCount,
prometheus.CounterValue,
float64(d.Count),
d.RPC,
)
ch <- prometheus.MustNewConstMetric(
descTime,
prometheus.CounterValue,
float64(d.Time),
d.RPC,
)
}
return nil
}
func (c *Collector) CollectBans(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetBans(ctx)
if err != nil {
return fmt.Errorf("get bans: %w", err)
}
desc := prometheus.NewDesc(
"monero_bans",
"number of nodes banned",
nil, nil,
)
ch <- prometheus.MustNewConstMetric(
desc,
prometheus.GaugeValue,
float64(len(res.Bans)),
)
return nil
}
func (c *Collector) CollectMempoolStats(ctx context.Context, ch chan<- prometheus.Metric) error {
res, err := c.client.GetTransactionPoolStats(ctx)
if err != nil {
return fmt.Errorf("get transaction pool: %w", err)
}
metrics, err := c.toMetrics("mempool", &res.PoolStats)
if err != nil {
return fmt.Errorf("to metrics: %w", err)
}
for _, metric := range metrics {
ch <- metric
}
return nil
}
func (c *Collector) toMetrics(ns string, res interface{}) ([]prometheus.Metric, error) {
var (
metrics = []prometheus.Metric{}
v = reflect.ValueOf(res).Elem()
err error
)
for i := 0; i < v.NumField(); i++ {
observation := float64(0)
field := v.Field(i)
switch field.Type().Kind() {
case reflect.Bool:
if field.Bool() {
observation = float64(1)
}
case
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64,
reflect.Uintptr:
observation, err = strconv.ParseFloat(fmt.Sprintf("%v", field.Interface()), 64)
if err != nil {
return nil, fmt.Errorf("parse float: %w", err)
}
default:
c.log.Info("ignoring",
"field", v.Type().Field(i).Name,
"type", field.Type().Kind().String(),
)
continue
}
tag := v.Type().Field(i).Tag.Get("json")
metrics = append(metrics, prometheus.MustNewConstMetric(
prometheus.NewDesc(
"monero_"+ns+"_"+tag,
"info for "+tag,
nil, nil,
),
prometheus.GaugeValue,
observation,
))
}
return metrics, nil
}

8
pkg/collector/doc.go Normal file
View File

@ -0,0 +1,8 @@
// Package collector provides the core functionality of this exporter.
//
// It implements the Prometheus collector interface, providing `monero` metrics
// whenever a request hits this exporter, allowing us to not have to rely on a
// particular interval defined in this exporter (instead, rely on prometheus'
// scrape interval).
//
package collector

4
pkg/exporter/doc.go Normal file
View File

@ -0,0 +1,4 @@
// Package exporter provides the ability for one to instantiate a web server
// that on requests to it, provides prometheus metrics in the http response.
//
package exporter

128
pkg/exporter/exporter.go Normal file
View File

@ -0,0 +1,128 @@
package exporter
import (
"context"
"fmt"
"net"
"net/http"
"github.com/go-logr/logr"
"github.com/go-logr/zapr"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
)
// Exporter is responsible for bringing up a web server that collects metrics
// that have been globally registered via prometheus collectors (e.g., see
// `pkg/collector`).
//
type Exporter struct {
// ListenAddress is the full address used by prometheus
// to listen for scraping requests.
//
// Examples:
// - :8080
// - 127.0.0.2:1313
//
listenAddress string
// TelemetryPath configures the path under which
// the prometheus metrics are reported.
//
// For instance:
// - /metrics
// - /telemetry
//
telemetryPath string
// listener is the TCP listener used by the webserver. `nil` if no
// server is running.
//
listener net.Listener
log logr.Logger
}
// Option.
//
type Option func(e *Exporter)
// New.
//
func New(opts ...Option) (*Exporter, error) {
defaultLogger, err := zap.NewDevelopment()
if err != nil {
return nil, fmt.Errorf("zap new development: %w", err)
}
e := &Exporter{
listenAddress: ":9000",
telemetryPath: "/metrics",
log: zapr.NewLogger(defaultLogger.Named("exporter")),
}
for _, opt := range opts {
opt(e)
}
return e, nil
}
// Run initiates the HTTP server to serve the metrics.
//
// ps.: this is a BLOCKING method - make sure you either make use of goroutines
// to not block if needed.
//
func (e *Exporter) Run(ctx context.Context) error {
var err error
e.listener, err = net.Listen("tcp", e.listenAddress)
if err != nil {
return fmt.Errorf("listen on '%s': %w", e.listenAddress, err)
}
doneChan := make(chan error, 1)
go func() {
defer close(doneChan)
e.log.WithValues(
"addr", e.listenAddress,
"path", e.telemetryPath,
).Info("listening")
http.Handle(e.telemetryPath, promhttp.Handler())
if err := http.Serve(e.listener, nil); err != nil {
doneChan <- fmt.Errorf(
"failed listening on address %s: %w",
e.listenAddress, err,
)
}
}()
select {
case err = <-doneChan:
if err != nil {
return fmt.Errorf("donechan err: %w", err)
}
case <-ctx.Done():
return fmt.Errorf("ctx err: %w", ctx.Err())
}
return nil
}
// Close gracefully closes the tcp listener associated with it.
//
func (e *Exporter) Close() (err error) {
if e.listener == nil {
return nil
}
e.log.Info("closing")
if err := e.listener.Close(); err != nil {
return fmt.Errorf("close: %w", err)
}
return nil
}

4
tools/doc.go Normal file
View File

@ -0,0 +1,4 @@
// Package tools simply imports the tooling that we use to maintain this
// repository, like testing utilities, linting, etc.
//
package tools

7
tools/tools.go Normal file
View File

@ -0,0 +1,7 @@
// +build tools
package tools
import (
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
)