From 09b6bd98124c2488da720300c260bbb3e2a656c7 Mon Sep 17 00:00:00 2001 From: Max Kureikin Date: Tue, 1 Oct 2024 12:15:51 +0400 Subject: [PATCH] Add Indexing latency metrics (#6) * Add Indexing latency metrics * Update README --- README.md | 38 ++++- cmd/ton-node-exporter/main.go | 5 +- go.mod | 2 +- go.sum | 4 +- internal/collector/indexing_latency.go | 58 +++++++ internal/collector/indexing_latency_test.go | 95 +++++++++++ ...block_number.go => master_block_number.go} | 0 ...er_test.go => master_block_number_test.go} | 0 internal/fetcher/adnl_fetcher.go | 37 +++++ internal/fetcher/adnl_fetcher_test.go | 148 +++++++++++++++++- internal/fetcher/fetcher.go | 1 + internal/fetcher/mocks/fetcher.go | 15 ++ 12 files changed, 389 insertions(+), 14 deletions(-) create mode 100644 internal/collector/indexing_latency.go create mode 100644 internal/collector/indexing_latency_test.go rename internal/collector/{block_number.go => master_block_number.go} (100%) rename internal/collector/{block_number_test.go => master_block_number_test.go} (100%) diff --git a/README.md b/README.md index f4eaeb0..42fc5c4 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,17 @@ A Prometheus metrics exporter for The Open Network (TON) Node. TON Node does not provide any built-in metrics for monitoring, so this exporter was created to fill that gap. ## Prerequisites + - Go 1.22 or later - Docker (optional) - Access to a TON Lite Server (Node) with a valid key. ## Installation + ### From source + To build and install the exporter from source, follow these steps: + ```bash git clone https://github.com/Maxitosh/ton-node-exporter.git cd ton-node-exporter @@ -18,13 +22,17 @@ go build -o ton-node-exporter ./cmd/ton-node-exporter ``` ### Docker + To pull the Docker image, use the following command: + ```bash docker pull ghcr.io/maxitosh/ton-node-exporter:latest ``` ## Usage + ### Configuration + The exporter is configured using environment variables or a `.env` file. The following variables are available: - `EXPORTER_PORT`: The port on which the exporter will listen (default: `9100`). @@ -32,18 +40,25 @@ The exporter is configured using environment variables or a `.env` file. The fol - `LITE_SERVER_KEY`: The key to access the TON Lite Server. - `GLOBAL_CONFIG_URL`: The URL of the TON global lite server config. -Check the .env.template file for an example and relevant environment variables. To create a .env file, copy the template: +Check the .env.template file for an example and relevant environment variables. To create a .env file, copy the +template: + ```bash cp .env.template .env ``` ## Running + ### From source + Assuming you set up the environment variables in the `.env` file, you can run the exporter with the following command: + ```bash ./ton-node-exporter ``` + If you want to use environment variables instead of a `.env` file, you can run the exporter like this: + ```bash export EXPORTER_PORT=9100 export LITE_SERVER_ADDR="" @@ -53,31 +68,42 @@ export GLOBAL_CONFIG_URL="" ``` ### Docker + To run the exporter using Docker: + ```bash docker run -d --name ton-node-exporter --env-file .env -p 9100:9100 ghcr.io/maxitosh/ton-node-exporter:latest ``` ## Metrics + The exporter exposes the following metrics: -| Metric name | Metric type | Description | Labels/tags | Status | -|------------------------------------|-------------|-----------------------------------------------------------------------------|-------------|--------| -| ton_node_master_chain_block_number | Gauge | The current master chain block number. | env | ✅ | -| ton_node_head_lag | Gauge | The lag between the current master chain block on the node and the network. | env | ✅ | +| Metric name | Metric type | Description | Labels/tags | Status | +|------------------------------------|-------------|--------------------------------------------------------------------------------|-------------|--------| +| ton_node_master_chain_block_number | Gauge | The current master chain block number. | env | ✅ | +| ton_node_head_lag | Gauge | The lag between the current master chain block on the node and the network. | | ✅ | +| ton_node_indexing_latency | Gauge | Time lag in seconds between the last Elector transaction and the current time. | | ✅ | +| ton_node_last_elector_tx_time | Gauge | Last Elector transaction time in seconds (Unix time). | | ✅ | ## Testing + To run tests, execute the following command: + ```bash go test -v ./... ``` ## Examples + To access the metrics, do curl request to the exporter: + ```bash curl http://localhost:9100/metrics ``` + Response: + ```plaintext ... # HELP ton_node_head_lag Head block lag @@ -90,7 +116,9 @@ ton_node_master_chain_block_number{env="local"} 3.9033746e+07 ``` ## Contributing + Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. ## Author + Created by [Maxitosh (Max Kureikin)](https://github.com/Maxitosh). diff --git a/cmd/ton-node-exporter/main.go b/cmd/ton-node-exporter/main.go index 098cfe4..c4fccff 100644 --- a/cmd/ton-node-exporter/main.go +++ b/cmd/ton-node-exporter/main.go @@ -44,7 +44,10 @@ func main() { fetcher.NewADNLFetcher(localAPI), fetcher.NewADNLFetcher(globalAPI), ) - prometheus.MustRegister(tonBlockNumberCollector) + tonIndexingLatencyCollector := collector.NewTonIndexingLatencyCollector( + fetcher.NewADNLFetcher(localAPI), + ) + prometheus.MustRegister(tonBlockNumberCollector, tonIndexingLatencyCollector) // Expose the registered metrics via HTTP. http.Handle("/metrics", promhttp.Handler()) diff --git a/go.mod b/go.mod index af5c5f6..7618a25 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/kelseyhightower/envconfig v1.4.0 github.com/prometheus/client_golang v1.19.1 github.com/stretchr/testify v1.9.0 - github.com/xssnick/tonutils-go v1.9.8 + github.com/xssnick/tonutils-go v1.10.2 go.uber.org/mock v0.4.0 ) diff --git a/go.sum b/go.sum index 14ec82d..ed1f28d 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/xssnick/tonutils-go v1.9.8 h1:Sq382w8H63sjy5y+j13b9mytHPLf7H94LW+OmxZ4h/c= -github.com/xssnick/tonutils-go v1.9.8/go.mod h1:p1l1Bxdv9sz6x2jfbuGQUGJn6g5cqg7xsTp8rBHFoJY= +github.com/xssnick/tonutils-go v1.10.2 h1:1wgnQPrzbOt+5PtuNrlMSUyh1/y0pvWRi0zeRNRLEbw= +github.com/xssnick/tonutils-go v1.10.2/go.mod h1:p1l1Bxdv9sz6x2jfbuGQUGJn6g5cqg7xsTp8rBHFoJY= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= diff --git a/internal/collector/indexing_latency.go b/internal/collector/indexing_latency.go new file mode 100644 index 0000000..f1dcad7 --- /dev/null +++ b/internal/collector/indexing_latency.go @@ -0,0 +1,58 @@ +package collector + +import ( + "time" + "ton-node-exporter/internal/fetcher" + + "github.com/prometheus/client_golang/prometheus" +) + +const ElectorAddress = "Ef8zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM0vF" + +var ( + tonLastElectorTxTimeDesc = prometheus.NewDesc( + "ton_node_last_elector_tx_time", + "Last Elector transaction time", + []string{}, nil, + ) + tonIndexingLatencyDesc = prometheus.NewDesc( + "ton_node_indexing_latency", + "Time lag between the last Elector transaction and the current time", + nil, nil, + ) +) + +var TimeNow = time.Now + +type TonIndexingLatencyCollector struct { + fetcher fetcher.Fetcher +} + +func NewTonIndexingLatencyCollector( + fetcher fetcher.Fetcher, +) *TonIndexingLatencyCollector { + return &TonIndexingLatencyCollector{ + fetcher: fetcher, + } +} + +func (collector *TonIndexingLatencyCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- tonLastElectorTxTimeDesc + ch <- tonIndexingLatencyDesc +} + +func (collector *TonIndexingLatencyCollector) Collect(ch chan<- prometheus.Metric) { + lastElectorTxTime, err := collector.fetcher.FetchAddressLastTransactionTime(ElectorAddress) + if err == nil { + ch <- prometheus.MustNewConstMetric( + tonLastElectorTxTimeDesc, + prometheus.GaugeValue, + float64(lastElectorTxTime), + ) + ch <- prometheus.MustNewConstMetric( + tonIndexingLatencyDesc, + prometheus.GaugeValue, + float64(TimeNow().Unix()-int64(lastElectorTxTime)), + ) + } +} diff --git a/internal/collector/indexing_latency_test.go b/internal/collector/indexing_latency_test.go new file mode 100644 index 0000000..72ff32b --- /dev/null +++ b/internal/collector/indexing_latency_test.go @@ -0,0 +1,95 @@ +package collector + +import ( + "strings" + "testing" + "time" + mock_fetcher "ton-node-exporter/internal/fetcher/mocks" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestTonIndexingLatencyCollector_Describe(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + fetcher := mock_fetcher.NewMockFetcher(ctrl) + collector := NewTonIndexingLatencyCollector(fetcher) + + ch := make(chan *prometheus.Desc, 2) + collector.Describe(ch) + close(ch) + + var descriptions []*prometheus.Desc + for desc := range ch { + descriptions = append(descriptions, desc) + } + + assert.Contains(t, descriptions, tonLastElectorTxTimeDesc) + assert.Contains(t, descriptions, tonIndexingLatencyDesc) +} + +func TestTonIndexingLatencyCollector_Collect(t *testing.T) { + tests := []struct { + name string + setupMocks func(fetcher *mock_fetcher.MockFetcher) + expectedMetrics string + expectedMetricCount int + }{ + { + name: "Successful fetch", + setupMocks: func(fetcher *mock_fetcher.MockFetcher) { + fetcher.EXPECT().FetchAddressLastTransactionTime(ElectorAddress).Return(uint32(1727767355), nil).AnyTimes() + }, + expectedMetrics: ` +# HELP ton_node_indexing_latency Time lag between the last Elector transaction and the current time +# TYPE ton_node_indexing_latency gauge +ton_node_indexing_latency 5 +# HELP ton_node_last_elector_tx_time Last Elector transaction time +# TYPE ton_node_last_elector_tx_time gauge +ton_node_last_elector_tx_time 1.727767355e+09 +`, + expectedMetricCount: 2, + }, + { + name: "Error in Indexing latency fetcher", + setupMocks: func(fetcher *mock_fetcher.MockFetcher) { + fetcher.EXPECT().FetchAddressLastTransactionTime(ElectorAddress).Return(uint32(0), assert.AnError).AnyTimes() + }, + expectedMetrics: ``, + expectedMetricCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + // 1727767355 + fixedTime := time.Date(2024, 10, 1, 7, 22, 40, 0, time.UTC) + TimeNow = func() time.Time { + return fixedTime + } + defer func() { TimeNow = time.Now }() // Reset after the test. + + fetcher := mock_fetcher.NewMockFetcher(ctrl) + tt.setupMocks(fetcher) + + collector := NewTonIndexingLatencyCollector(fetcher) + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(collector) + + metrics, err := reg.Gather() + assert.NoError(t, err) + assert.Equal(t, tt.expectedMetricCount, len(metrics)) + + if tt.expectedMetricCount > 0 { + err := testutil.GatherAndCompare(reg, strings.NewReader(tt.expectedMetrics)) + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/collector/block_number.go b/internal/collector/master_block_number.go similarity index 100% rename from internal/collector/block_number.go rename to internal/collector/master_block_number.go diff --git a/internal/collector/block_number_test.go b/internal/collector/master_block_number_test.go similarity index 100% rename from internal/collector/block_number_test.go rename to internal/collector/master_block_number_test.go diff --git a/internal/fetcher/adnl_fetcher.go b/internal/fetcher/adnl_fetcher.go index a6f81cf..9f05e85 100644 --- a/internal/fetcher/adnl_fetcher.go +++ b/internal/fetcher/adnl_fetcher.go @@ -4,11 +4,21 @@ import ( "context" "time" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/ton" ) type TonAPIClient interface { GetMasterchainInfo(ctx context.Context) (*ton.BlockIDExt, error) + GetAccount(ctx context.Context, block *ton.BlockIDExt, addr *address.Address) (*tlb.Account, error) + ListTransactions( + ctx context.Context, + addr *address.Address, + num uint32, + lt uint64, + txHash []byte, + ) ([]*tlb.Transaction, error) } type ADNLFetcher struct { @@ -32,3 +42,30 @@ func (fetcher *ADNLFetcher) FetchMasterChainBlockNumber() (float64, error) { return float64(masterChainInfo.SeqNo), nil } + +func (fetcher *ADNLFetcher) FetchAddressLastTransactionTime(addr string) (uint32, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // we need fresh master block info to run get methods + masterChainInfo, err := fetcher.client.GetMasterchainInfo(ctx) + if err != nil { + return 0, err + } + + // fetch account info to get last tx hash and lt + addrParsed := address.MustParseAddr(addr) + account, err := fetcher.client.GetAccount(ctx, masterChainInfo, addrParsed) + if err != nil { + return 0, err + } + + // fetch last transaction + txs, err := fetcher.client.ListTransactions(ctx, address.MustParseAddr(addr), 1, account.LastTxLT, account.LastTxHash) + if err != nil { + return 0, err + } + + electorTx := txs[0] + return electorTx.Now, nil +} diff --git a/internal/fetcher/adnl_fetcher_test.go b/internal/fetcher/adnl_fetcher_test.go index 6ad2c87..88c77bb 100644 --- a/internal/fetcher/adnl_fetcher_test.go +++ b/internal/fetcher/adnl_fetcher_test.go @@ -6,18 +6,65 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/ton" ) type MockAPIClient struct { mock.Mock MockGetMasterchainInfo func(ctx context.Context) (*ton.BlockIDExt, error) + MockGetAccount func(ctx context.Context, block *ton.BlockIDExt, addr *address.Address) (*tlb.Account, error) + MockListTransactions func( + ctx context.Context, + addr *address.Address, + limit uint32, + lt uint64, + txHash []byte, + ) ([]*tlb.Transaction, error) } func (m *MockAPIClient) GetMasterchainInfo(ctx context.Context) (*ton.BlockIDExt, error) { return m.MockGetMasterchainInfo(ctx) } +func (m *MockAPIClient) GetAccount( + ctx context.Context, + block *ton.BlockIDExt, + addr *address.Address, +) (*tlb.Account, error) { + return m.MockGetAccount(ctx, block, addr) +} + +func (m *MockAPIClient) ListTransactions( + ctx context.Context, + addr *address.Address, + limit uint32, + lt uint64, + txHash []byte, +) ([]*tlb.Transaction, error) { + return m.MockListTransactions(ctx, addr, limit, lt, txHash) +} + +// Helper function to create a mock client. +func createMockClient( + mockInfo func(ctx context.Context) (*ton.BlockIDExt, error), + mockAccount func(ctx context.Context, block *ton.BlockIDExt, addr *address.Address) (*tlb.Account, error), + mockTransactions func( + ctx context.Context, + addr *address.Address, + limit uint32, + lt uint64, + txHash []byte, + ) ([]*tlb.Transaction, error), +) *MockAPIClient { + return &MockAPIClient{ + MockGetMasterchainInfo: mockInfo, + MockGetAccount: mockAccount, + MockListTransactions: mockTransactions, + } +} + func TestADNLFetcher_FetchMasterChainBlockNumber(t *testing.T) { tests := []struct { name string @@ -44,14 +91,105 @@ func TestADNLFetcher_FetchMasterChainBlockNumber(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mockClient := &MockAPIClient{ - MockGetMasterchainInfo: func(ctx context.Context) (*ton.BlockIDExt, error) { - return tt.mockResponse, tt.mockError - }, + mockClient := createMockClient(func(ctx context.Context) (*ton.BlockIDExt, error) { + return tt.mockResponse, tt.mockError + }, nil, nil) + + fetcher := NewADNLFetcher(mockClient) + result, err := fetcher.FetchMasterChainBlockNumber() + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) } + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestADNLFetcher_FetchAddressLastTransactionTime(t *testing.T) { + tests := []struct { + name string + mockInfo func(ctx context.Context) (*ton.BlockIDExt, error) + mockAccount func(ctx context.Context, block *ton.BlockIDExt, addr *address.Address) (*tlb.Account, error) + mockTxs func( + ctx context.Context, + addr *address.Address, + limit uint32, + lt uint64, + txHash []byte, + ) ([]*tlb.Transaction, error) + expected uint32 + expectError bool + }{ + { + name: "Successful fetch", + mockInfo: func(ctx context.Context) (*ton.BlockIDExt, error) { + return &ton.BlockIDExt{SeqNo: 42}, nil + }, + mockAccount: func(ctx context.Context, block *ton.BlockIDExt, addr *address.Address) (*tlb.Account, error) { + return &tlb.Account{LastTxLT: 123, LastTxHash: []byte("hash")}, nil + }, + mockTxs: func( + ctx context.Context, + addr *address.Address, + limit uint32, + lt uint64, + txHash []byte, + ) ([]*tlb.Transaction, error) { + return []*tlb.Transaction{{Now: 456}}, nil + }, + expected: 456, + expectError: false, + }, + { + name: "Error fetching master block", + mockInfo: func(ctx context.Context) (*ton.BlockIDExt, error) { + return nil, assert.AnError + }, + expected: 0, + expectError: true, + }, + { + name: "Error fetching account", + mockInfo: func(ctx context.Context) (*ton.BlockIDExt, error) { + return &ton.BlockIDExt{SeqNo: 42}, nil + }, + mockAccount: func(ctx context.Context, block *ton.BlockIDExt, addr *address.Address) (*tlb.Account, error) { + return nil, assert.AnError + }, + expected: 0, + expectError: true, + }, + { + name: "Error fetching transaction", + mockInfo: func(ctx context.Context) (*ton.BlockIDExt, error) { + return &ton.BlockIDExt{SeqNo: 42}, nil + }, + mockAccount: func(ctx context.Context, block *ton.BlockIDExt, addr *address.Address) (*tlb.Account, error) { + return &tlb.Account{LastTxLT: 123, LastTxHash: []byte("hash")}, nil + }, + mockTxs: func( + ctx context.Context, + addr *address.Address, + limit uint32, + lt uint64, + txHash []byte, + ) ([]*tlb.Transaction, error) { + return nil, assert.AnError + }, + expected: 0, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := createMockClient(tt.mockInfo, tt.mockAccount, tt.mockTxs) fetcher := NewADNLFetcher(mockClient) - result, err := fetcher.FetchMasterChainBlockNumber() + result, err := fetcher.FetchAddressLastTransactionTime("Ef8zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM0vF") if tt.expectError { assert.Error(t, err) diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 42326d8..def22cb 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -3,4 +3,5 @@ package fetcher //go:generate mockgen -source=fetcher.go -destination=mocks/fetcher.go -package=mock_fetcher Fetcher type Fetcher interface { FetchMasterChainBlockNumber() (float64, error) + FetchAddressLastTransactionTime(addr string) (uint32, error) } diff --git a/internal/fetcher/mocks/fetcher.go b/internal/fetcher/mocks/fetcher.go index 0fcdaeb..6f492b6 100644 --- a/internal/fetcher/mocks/fetcher.go +++ b/internal/fetcher/mocks/fetcher.go @@ -38,6 +38,21 @@ func (m *MockFetcher) EXPECT() *MockFetcherMockRecorder { return m.recorder } +// FetchAddressLastTransactionTime mocks base method. +func (m *MockFetcher) FetchAddressLastTransactionTime(addr string) (uint32, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FetchAddressLastTransactionTime", addr) + ret0, _ := ret[0].(uint32) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchAddressLastTransactionTime indicates an expected call of FetchAddressLastTransactionTime. +func (mr *MockFetcherMockRecorder) FetchAddressLastTransactionTime(addr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchAddressLastTransactionTime", reflect.TypeOf((*MockFetcher)(nil).FetchAddressLastTransactionTime), addr) +} + // FetchMasterChainBlockNumber mocks base method. func (m *MockFetcher) FetchMasterChainBlockNumber() (float64, error) { m.ctrl.T.Helper()