Repository: tablelandnetwork/go-tableland
Pull request #334 (2022-10-07 15:57:10)

Telemetry: add binary git summary & last executed block number per chain id
# Summary

This PR adds two new metrics to the optional telemetry collection:
- The Git summary of the validator binary: This will help understand if validators are running old versions.
- Last executed block number per chain: this will help to understand if validators have some healthiness problem syncing chains, lagging behind; we could infer syncing speeds or liveness since the metric is published at a fixed time-frequency.

This was tested in staging, and it's working correctly.

# Context

This is part of the objective to add optional telemetry reporting in validators.

# Implementation overview

The implementation has two main parts:
1) Git summary metrics: this is published once when the validator spins up. 
2) Last executed block number: this is done by a new telemetry sub-package, collecting the information in all `chainStacks`, and aggregating them in a single metric to be published.

# Implementation details and review orientation

No specific directions are needed. I'll add some PR comments to mention some tangential refactors.

# Checklist

- [X]  Are changes backward compatible with existing SDKs, or is there a plan to manage it correctly?
- [X]  Are changes covered by existing tests, or were new tests included?
- [X]  Are code changes optimized for future code readers, commenting on problematic areas to understand (if any)?
- [X]  Future-self question: Did you avoid unjustified/unnecessary complexity to achieve the goal?



Patch:
diff --git a/buildinfo/buildinfo.go b/buildinfo/buildinfo.go
index 7f7bb8d..e465e19 100644
--- a/buildinfo/buildinfo.go
+++ b/buildinfo/buildinfo.go
@@ -25,7 +25,7 @@ type Summary struct {
 	Version    string
 }
 
-// GetSummary returns a JSON string with a summary of git information.
+// GetSummary returns a summary of git information.
 func GetSummary() Summary {
 	summary := Summary{
 		GitCommit:  GitCommit,
@@ -37,3 +37,21 @@ func GetSummary() Summary {
 	}
 	return summary
 }
+
+// GetGitCommit returns the GitCommit.
+func (s Summary) GetGitCommit() string { return s.GitCommit }
+
+// GetGitBranch returns the GitBranch.
+func (s Summary) GetGitBranch() string { return s.GitBranch }
+
+// GetGitState returns the GitState.
+func (s Summary) GetGitState() string { return s.GitState }
+
+// GetGitSummary returns the GitSummary.
+func (s Summary) GetGitSummary() string { return s.GitSummary }
+
+// GetBuildDate returns the build date.
+func (s Summary) GetBuildDate() string { return s.BuildDate }
+
+// GetBinaryVersion returns the binary version.
+func (s Summary) GetBinaryVersion() string { return s.Version }
diff --git a/cmd/api/config.go b/cmd/api/config.go
index dcaa528..b0853d6 100644
--- a/cmd/api/config.go
+++ b/cmd/api/config.go
@@ -55,12 +55,17 @@ type config struct {
 			KeepFiles int  `default:"5"` // number of files to keep
 		}
 	}
-	TelemetryPublisher struct {
-		Enabled            bool   `default:"false"`
-		MetricsHubURL      string `default:""`
-		MetricsHubAPIKey   string `default:""`
-		PublishingInterval string `default:"10s"`
-	}
+	TelemetryPublisher TelemetryPublisherConfig
+}
+
+// TelemetryPublisherConfig contains configuration attributes for the telemetry module.
+type TelemetryPublisherConfig struct {
+	Enabled            bool   `default:"false"`
+	MetricsHubURL      string `default:""`
+	MetricsHubAPIKey   string `default:""`
+	PublishingInterval string `default:"10s"`
+
+	ChainStackCollectFrequency string `default:"15m"`
 }
 
 // TableConstraints describes contraints to be enforced for Tableland tables.
diff --git a/cmd/api/main.go b/cmd/api/main.go
index e20814a..df9bb95 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -39,12 +39,15 @@ import (
 	"github.com/textileio/go-tableland/pkg/sqlstore/impl/user"
 	"github.com/textileio/go-tableland/pkg/tables/impl/ethereum"
 	"github.com/textileio/go-tableland/pkg/telemetry"
+	"github.com/textileio/go-tableland/pkg/telemetry/chainscollector"
 	"github.com/textileio/go-tableland/pkg/telemetry/publisher"
 	"github.com/textileio/go-tableland/pkg/telemetry/storage"
 	"github.com/textileio/go-tableland/pkg/wallet"
 	"go.opentelemetry.io/otel/attribute"
 )
 
+type moduleClose func(ctx context.Context) error
+
 func main() {
 	config, dirPath := setupConfig()
 	logging.SetupLogger(buildinfo.GitCommit, config.Log.Debug, config.Log.Human)
@@ -115,18 +118,6 @@ func main() {
 		log.Fatal().Err(err).Msg("creating user store")
 	}
 
-	metricsDatabaseURL := fmt.Sprintf(
-		"file://%s?_busy_timeout=5000&_foreign_keys=on&_journal_mode=WAL",
-		path.Join(dirPath, "metrics.db"),
-	)
-
-	metricsStore, err := storage.New(metricsDatabaseURL)
-	if err != nil {
-		log.Fatal().Err(err).Msg("creating metrics store")
-	}
-
-	telemetry.SetMetricStore(metricsStore)
-
 	rateLimInterval, err := time.ParseDuration(config.HTTP.RateLimInterval)
 	if err != nil {
 		log.Fatal().Err(err).Msg("parsing http rate lim interval")
@@ -232,21 +223,9 @@ func main() {
 
 	log.Info().Str("node_id", nodeID).Msg("node info")
 
-	var metricsPublisher *publisher.Publisher
-	if config.TelemetryPublisher.Enabled {
-		exporter, err := publisher.NewHTTPExporter(
-			config.TelemetryPublisher.MetricsHubURL,
-			config.TelemetryPublisher.MetricsHubAPIKey,
-		)
-		if err != nil {
-			log.Fatal().Err(err).Msg("creating metrics http exporter")
-		}
-		publishingInterval, err := time.ParseDuration(config.TelemetryPublisher.PublishingInterval)
-		if err != nil {
-			log.Fatal().Err(err).Msg("parsing publising interval")
-		}
-		metricsPublisher = publisher.NewPublisher(metricsStore, exporter, nodeID, publishingInterval)
-		metricsPublisher.Start()
+	closeTelemetryModule, err := configureTelemetry(nodeID, dirPath, chainStacks, config.TelemetryPublisher)
+	if err != nil {
+		log.Fatal().Err(err).Msg("configuring telemetry")
 	}
 
 	cli.HandleInterrupt(func() {
@@ -279,16 +258,13 @@ func main() {
 			log.Error().Err(err).Msg("closing user store")
 		}
 
-		if err := metricsStore.Close(); err != nil {
-			log.Error().Err(err).Msg("closing metrics store")
-		}
-
-		if config.TelemetryPublisher.Enabled {
-			metricsPublisher.Close()
-		}
 		if err := executorsDB.Close(); err != nil {
 			log.Error().Err(err).Msg("closing executors db")
 		}
+
+		if err := closeTelemetryModule(ctx); err != nil {
+			log.Error().Err(err).Msg("closing telemetry module")
+		}
 	})
 }
 
@@ -404,6 +380,7 @@ func createChainIDStack(
 	return chains.ChainStack{
 		Store:                 systemStore,
 		Registry:              registry,
+		EventProcessor:        ep,
 		AllowTransactionRelay: config.AllowTransactionRelay,
 		Close: func(ctx context.Context) error {
 			log.Info().Int64("chain_id", int64(config.ChainID)).Msg("closing stack...")
@@ -419,3 +396,77 @@ func createChainIDStack(
 		},
 	}, nil
 }
+
+func configureTelemetry(
+	nodeID string,
+	dirPath string,
+	chainStacks map[tableland.ChainID]chains.ChainStack,
+	config TelemetryPublisherConfig,
+) (moduleClose, error) {
+	// Wiring
+	metricsDatabaseURL := fmt.Sprintf(
+		"file://%s?_busy_timeout=5000&_foreign_keys=on&_journal_mode=WAL",
+		path.Join(dirPath, "metrics.db"),
+	)
+
+	metricsStore, err := storage.New(metricsDatabaseURL)
+	if err != nil {
+		return nil, fmt.Errorf("creating metrics store: %s", err)
+	}
+
+	var metricsPublisher *publisher.Publisher
+	if config.Enabled {
+		exporter, err := publisher.NewHTTPExporter(
+			config.MetricsHubURL,
+			config.MetricsHubAPIKey,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("creating metrics http exporter: %s", err)
+		}
+		publishingInterval, err := time.ParseDuration(config.PublishingInterval)
+		if err != nil {
+			return nil, fmt.Errorf("parsing publishing interval: %s", err)
+		}
+		metricsPublisher = publisher.NewPublisher(metricsStore, exporter, nodeID, publishingInterval)
+		metricsPublisher.Start()
+	}
+	telemetry.SetMetricStore(metricsStore)
+
+	// Collect binary information.
+	ctx, cls := context.WithTimeout(context.Background(), time.Second)
+	defer cls()
+	if err := telemetry.Collect(ctx, buildinfo.GetSummary()); err != nil {
+		return nil, fmt.Errorf("collect git summary: %s", err)
+	}
+
+	collectFrequency, err := time.ParseDuration(config.ChainStackCollectFrequency)
+	if err != nil {
+		return nil, fmt.Errorf("invalid chain stack collect frequency configuration: %s", err)
+	}
+	ctxChainStackCollector, clsChainStackCollector := context.WithCancel(context.Background())
+	chainStackCollectorClosed := make(chan struct{})
+	cc, err := chainscollector.New(chainStacks, collectFrequency)
+	if err != nil {
+		clsChainStackCollector()
+		return nil, fmt.Errorf("configure chains collector: %s", err)
+	}
+	go func() {
+		cc.Start(ctxChainStackCollector)
+		close(chainStackCollectorClosed)
+	}()
+
+	// Module closing function
+	return func(ctx context.Context) error {
+		clsChainStackCollector()
+		<-chainStackCollectorClosed
+
+		if err := metricsStore.Close(); err != nil {
+			return fmt.Errorf("closing metrics store: %s", err)
+		}
+		if config.Enabled {
+			metricsPublisher.Close()
+		}
+
+		return nil
+	}, nil
+}
diff --git a/docker/deployed/staging/api/config.json b/docker/deployed/staging/api/config.json
index da7a7e0..3d27d90 100644
--- a/docker/deployed/staging/api/config.json
+++ b/docker/deployed/staging/api/config.json
@@ -45,7 +45,8 @@
     "Enabled": true,
     "MetricsHubURL": "https://metricshub-staging-mrgr43cf5q-uw.a.run.app",
     "MetricsHubApiKey": "${METRICS_HUB_API_KEY}",
-    "PublishingInterval": "10s"
+    "PublishingInterval": "10s",
+    "ChainStackCollectFrequency": "15m"
   },
   "Chains": [
     {
diff --git a/internal/chains/chains.go b/internal/chains/chains.go
index 1fd0720..e547abf 100644
--- a/internal/chains/chains.go
+++ b/internal/chains/chains.go
@@ -3,6 +3,7 @@ package chains
 import (
 	"context"
 
+	"github.com/textileio/go-tableland/pkg/eventprocessor"
 	"github.com/textileio/go-tableland/pkg/sqlstore"
 	"github.com/textileio/go-tableland/pkg/tables"
 )
@@ -11,6 +12,7 @@ import (
 type ChainStack struct {
 	Store                 sqlstore.SystemStore
 	Registry              tables.TablelandTables
+	EventProcessor        eventprocessor.EventProcessor
 	AllowTransactionRelay bool
 
 	// close gracefully closes all the chain stack components.
diff --git a/pkg/eventprocessor/eventprocessor.go b/pkg/eventprocessor/eventprocessor.go
index 7eeb51b..fea1c5d 100644
--- a/pkg/eventprocessor/eventprocessor.go
+++ b/pkg/eventprocessor/eventprocessor.go
@@ -1,6 +1,7 @@
 package eventprocessor
 
 import (
+	"context"
 	"fmt"
 	"time"
 
@@ -66,6 +67,7 @@ func WithHashCalcStep(step int64) Option {
 
 // EventProcessor processes events from a smart-contract.
 type EventProcessor interface {
+	GetLastExecutedBlockNumber(context.Context) (int64, error)
 	Start() error
 	Stop()
 }
diff --git a/pkg/eventprocessor/impl/eventprocessor.go b/pkg/eventprocessor/impl/eventprocessor.go
index 88c3fc4..8852f8e 100644
--- a/pkg/eventprocessor/impl/eventprocessor.go
+++ b/pkg/eventprocessor/impl/eventprocessor.go
@@ -112,6 +112,16 @@ func (ep *EventProcessor) Start() error {
 	return nil
 }
 
+// GetLastExecutedBlockNumber returns the last executed block number.
+func (ep *EventProcessor) GetLastExecutedBlockNumber(ctx context.Context) (int64, error) {
+	blockNumber, err := ep.executor.GetLastExecutedBlockNumber(ctx)
+	if err != nil {
+		return 0, fmt.Errorf("get last executed block number: %s", err)
+	}
+
+	return blockNumber, nil
+}
+
 // Stop stops processing new events.
 func (ep *EventProcessor) Stop() {
 	ep.lock.Lock()
diff --git a/pkg/telemetry/chainscollector/chainscollector.go b/pkg/telemetry/chainscollector/chainscollector.go
new file mode 100644
index 0000000..ab6966f
--- /dev/null
+++ b/pkg/telemetry/chainscollector/chainscollector.go
@@ -0,0 +1,65 @@
+package chainscollector
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/rs/zerolog"
+	logger "github.com/rs/zerolog/log"
+	"github.com/textileio/go-tableland/internal/chains"
+	"github.com/textileio/go-tableland/internal/tableland"
+	"github.com/textileio/go-tableland/pkg/telemetry"
+)
+
+// ChainsCollector captures metrics from chains stacks with a defined frequency.
+type ChainsCollector struct {
+	log              zerolog.Logger
+	chainStacks      map[tableland.ChainID]chains.ChainStack
+	collectFrequency time.Duration
+}
+
+// New returns a new *ChainsCollector.
+func New(
+	chainStacks map[tableland.ChainID]chains.ChainStack,
+	collectFrequency time.Duration,
+) (*ChainsCollector, error) {
+	if collectFrequency <= time.Second {
+		return nil, fmt.Errorf("collect frequency should be greater than one second")
+	}
+	return &ChainsCollector{
+		log:              logger.With().Str("component", "chainscollector").Logger(),
+		chainStacks:      chainStacks,
+		collectFrequency: collectFrequency,
+	}, nil
+}
+
+// Start starts collecting chains stack telemetry metrics until the context is canceled.
+func (cc *ChainsCollector) Start(ctx context.Context) {
+	for {
+		select {
+		case <-ctx.Done():
+			cc.log.Info().Msg("gracefully closed")
+			return
+		case <-time.After(cc.collectFrequency):
+			metric := make(chainIDBlockNumbers, len(cc.chainStacks))
+			for chainID, chainStack := range cc.chainStacks {
+				blockNumber, err := chainStack.EventProcessor.GetLastExecutedBlockNumber(ctx)
+				if err != nil {
+					cc.log.Error().Err(err).Msg("get last executed block number")
+					continue
+				}
+				metric[chainID] = blockNumber
+			}
+			if err := telemetry.Collect(ctx, metric); err != nil {
+				cc.log.Error().Err(err).Msg("collecting chain stack metric")
+			}
+		}
+	}
+}
+
+type chainIDBlockNumbers map[tableland.ChainID]int64
+
+func (cbn chainIDBlockNumbers) GetLastProcessedBlockNumber() map[tableland.ChainID]int64 {
+	return cbn
+}
diff --git a/pkg/telemetry/metrics.go b/pkg/telemetry/metrics.go
index ec23c82..cac50ea 100644
--- a/pkg/telemetry/metrics.go
+++ b/pkg/telemetry/metrics.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/pkg/errors"
+	"github.com/textileio/go-tableland/internal/tableland"
 )
 
 // MetricType defines the metric type.
@@ -13,6 +14,10 @@ type MetricType int
 const (
 	// StateHashType is the type for the StateHashMetric.
 	StateHashType MetricType = iota
+	// GitSummaryType is the type for the GitSummaryMetric.
+	GitSummaryType
+	// ChainStacksSummaryType is the type for the ChainStacksMetric.
+	ChainStacksSummaryType
 )
 
 // Metric defines a metric.
@@ -43,8 +48,43 @@ type StateHash interface {
 
 // StateHashMetric defines a state hash metric.
 type StateHashMetric struct {
-	Version     int64  `json:"version"`
+	Version int64 `json:"version"`
+
 	ChainID     int64  `json:"chain_id"`
 	BlockNumber int64  `json:"block_number"`
 	Hash        string `json:"hash"`
 }
+
+// GitSummary defines how data is accessed to create a VersionSummaryMetric.
+type GitSummary interface {
+	GetGitCommit() string
+	GetGitBranch() string
+	GetGitState() string
+	GetGitSummary() string
+	GetBuildDate() string
+	GetBinaryVersion() string
+}
+
+// GitSummaryMetric contains Git information of the binary.
+type GitSummaryMetric struct {
+	Version int `json:"version"`
+
+	GitCommit     string `json:"git_commit"`
+	GitBranch     string `json:"git_branch"`
+	GitState      string `json:"git_state"`
+	GitSummary    string `json:"git_summary"`
+	BuildDate     string `json:"build_date"`
+	BinaryVersion string `json:"binary_version"`
+}
+
+// ChainStacksSummary defines how data is accessed to create a ChainStacksMetric.
+type ChainStacksSummary interface {
+	GetLastProcessedBlockNumber() map[tableland.ChainID]int64
+}
+
+// ChainStacksMetric contains information about each chain being synced.
+type ChainStacksMetric struct {
+	Version int `json:"version"`
+
+	LastProcessedBlockNumbers map[tableland.ChainID]int64 `json:"last_processed_block_number"`
+}
diff --git a/pkg/telemetry/storage/db.go b/pkg/telemetry/storage/db.go
index 845f9e0..93c82dc 100644
--- a/pkg/telemetry/storage/db.go
+++ b/pkg/telemetry/storage/db.go
@@ -86,10 +86,12 @@ func (db *TelemetryDatabase) FetchUnpublishedMetrics(ctx context.Context, amount
 		amount,
 	)
 	if err != nil {
-		return []telemetry.Metric{}, fmt.Errorf("query system metrics: %s", err)
+		return nil, fmt.Errorf("query system metrics: %s", err)
 	}
 	defer func() {
-		_ = rows.Close()
+		if err = rows.Close(); err != nil {
+			db.log.Error().Err(err).Msg("closing query rows")
+		}
 	}()
 
 	var metrics []telemetry.Metric
@@ -98,7 +100,7 @@ func (db *TelemetryDatabase) FetchUnpublishedMetrics(ctx context.Context, amount
 		var payload []byte
 		var version int
 		if err := rows.Scan(&rowid, &version, &timestamp, &typ, &payload, &published); err != nil {
-			return []telemetry.Metric{}, fmt.Errorf("scan rows of system metrics: %s", err)
+			return nil, fmt.Errorf("scan rows of system metrics: %s", err)
 		}
 
 		var mPayload interface{}
@@ -107,11 +109,24 @@ func (db *TelemetryDatabase) FetchUnpublishedMetrics(ctx context.Context, amount
 		case telemetry.StateHashType:
 			mPayload = new(telemetry.StateHashMetric)
 			if err := json.Unmarshal(payload, mPayload); err != nil {
-				return []telemetry.Metric{}, fmt.Errorf("scan rows of system metrics: %s", err)
+				return nil, fmt.Errorf("scan rows of system metrics: %s", err)
 			}
 			mType = telemetry.StateHashType
+		case telemetry.GitSummaryType:
+			mPayload = new(telemetry.GitSummaryMetric)
+			if err := json.Unmarshal(payload, mPayload); err != nil {
+				return nil, fmt.Errorf("scan rows of system metrics: %s", err)
+			}
+			mType = telemetry.GitSummaryType
+		case telemetry.ChainStacksSummaryType:
+			mPayload = new(telemetry.ChainStacksMetric)
+			if err := json.Unmarshal(payload, mPayload); err != nil {
+				return nil, fmt.Errorf("scan rows of system metrics: %s", err)
+			}
+			mType = telemetry.ChainStacksSummaryType
+
 		default:
-			return []telemetry.Metric{}, fmt.Errorf("unknown metric type: %d", typ)
+			return nil, fmt.Errorf("unknown metric type: %d", typ)
 		}
 
 		metrics = append(metrics, telemetry.Metric{
diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go
index f4bec94..094bf68 100644
--- a/pkg/telemetry/telemetry.go
+++ b/pkg/telemetry/telemetry.go
@@ -62,7 +62,38 @@ func Collect(ctx context.Context, metric interface{}) error {
 				Hash:        v.Hash(),
 			},
 		}); err != nil {
-			return errors.Errorf("store metric: %s", err)
+			return errors.Errorf("store state hash metric: %s", err)
+		}
+		return nil
+	case GitSummary:
+		if err := metricStore.StoreMetric(ctx, Metric{
+			Version:   1,
+			Timestamp: time.Now().UTC(),
+			Type:      GitSummaryType,
+			Payload: GitSummaryMetric{
+				Version:       1,
+				GitCommit:     v.GetGitCommit(),
+				GitBranch:     v.GetGitBranch(),
+				GitState:      v.GetGitState(),
+				GitSummary:    v.GetGitSummary(),
+				BuildDate:     v.GetBuildDate(),
+				BinaryVersion: v.GetBinaryVersion(),
+			},
+		}); err != nil {
+			return errors.Errorf("store git summary metric: %s", err)
+		}
+		return nil
+	case ChainStacksSummary:
+		if err := metricStore.StoreMetric(ctx, Metric{
+			Version:   1,
+			Timestamp: time.Now().UTC(),
+			Type:      ChainStacksSummaryType,
+			Payload: ChainStacksMetric{
+				Version:                   1,
+				LastProcessedBlockNumbers: v.GetLastProcessedBlockNumber(),
+			},
+		}); err != nil {
+			return errors.Errorf("store chains stacks summary metric: %s", err)
 		}
 		return nil
 	default:


Test patch:
diff --git a/docker/deployed/testnet/api/config.json b/docker/deployed/testnet/api/config.json
index 3f4ffa2..4ecedfd 100644
--- a/docker/deployed/testnet/api/config.json
+++ b/docker/deployed/testnet/api/config.json
@@ -46,7 +46,8 @@
         "Enabled": true,
         "MetricsHubURL": "https://metricshub-testnet-mrgr43cf5q-uw.a.run.app",
         "MetricsHubApiKey": "${METRICS_HUB_API_KEY}",
-        "PublishingInterval": "10s"
+        "PublishingInterval": "10s",
+        "ChainStackCollectFrequency": "15m"
     },
     "Chains": [
         {
diff --git a/pkg/telemetry/storage/db_test.go b/pkg/telemetry/storage/db_test.go
index debfd3d..23cabeb 100644
--- a/pkg/telemetry/storage/db_test.go
+++ b/pkg/telemetry/storage/db_test.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
+	"github.com/textileio/go-tableland/internal/tableland"
 	"github.com/textileio/go-tableland/pkg/telemetry"
 	"github.com/textileio/go-tableland/pkg/telemetry/publisher"
 
@@ -17,12 +18,21 @@ import (
 )
 
 func TestCollectAndFetchAndPublish(t *testing.T) {
-	t.Run("state hash", func(t *testing.T) {
-		dbURI := tests.Sqlite3URI(t)
-		s, err := New(dbURI)
-		require.NoError(t, err)
-		telemetry.SetMetricStore(s)
+	t.Parallel()
+
+	// Notes:
+	// This can't be wired per sub-tests for two reasons:
+	// 1- `telemetry.SetMetricStore(...)` is a global setup at the package level, and
+	// 2- `SetMetricStore(...)` has a `sync.Once` wrapping so can't be called more than once, so each sub-test can't
+	//     override their value.
+	//
+	// This also means that sub-tests can't run in parallel.
+	dbURI := tests.Sqlite3URI(t)
+	s, err := New(dbURI)
+	require.NoError(t, err)
+	telemetry.SetMetricStore(s)
 
+	t.Run("state hash", func(t *testing.T) {
 		// collect two mocked statehash metrics
 		require.NoError(t, telemetry.Collect(context.Background(), stateHash{}))
 		require.NoError(t, telemetry.Collect(context.Background(), stateHash{}))
@@ -31,17 +41,100 @@ func TestCollectAndFetchAndPublish(t *testing.T) {
 		require.NoError(t, err)
 		require.Len(t, metrics, 2)
 
-		require.Equal(t, telemetry.StateHashType, metrics[0].Type)
-		require.Equal(t, 1, metrics[0].Version)
-		require.Equal(t, stateHash{}.ChainID(), metrics[0].Payload.(*telemetry.StateHashMetric).ChainID)
-		require.Equal(t, stateHash{}.BlockNumber(), metrics[0].Payload.(*telemetry.StateHashMetric).BlockNumber)
-		require.Equal(t, stateHash{}.Hash(), metrics[0].Payload.(*telemetry.StateHashMetric).Hash)
+		for _, metric := range metrics {
+			require.Equal(t, telemetry.StateHashType, metric.Type)
+			require.Equal(t, 1, metric.Version)
+			require.False(t, metric.Timestamp.IsZero())
+
+			sh := metric.Payload.(*telemetry.StateHashMetric)
+			require.Equal(t, stateHash{}.ChainID(), sh.ChainID)
+			require.Equal(t, stateHash{}.BlockNumber(), sh.BlockNumber)
+			require.Equal(t, stateHash{}.Hash(), sh.Hash)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusOK)
+		}))
+		defer ts.Close()
+
+		exporter, err := publisher.NewHTTPExporter(ts.URL, "")
+		require.NoError(t, err)
+		nodeID := strings.Replace(uuid.NewString(), "-", "", -1)
+		p := publisher.NewPublisher(s, exporter, nodeID, time.Second)
+		p.Start()
+
+		require.Eventually(t, func() bool {
+			metrics, err = s.FetchUnpublishedMetrics(context.Background(), 2)
+			require.NoError(t, err)
+			return len(metrics) == 0
+		}, 5*time.Second, time.Second)
+
+		p.Close()
+	})
+
+	t.Run("git summary", func(t *testing.T) {
+		// collect two mocked gitSummary metrics
+		require.NoError(t, telemetry.Collect(context.Background(), gitSummary{}))
+		require.NoError(t, telemetry.Collect(context.Background(), gitSummary{}))
+
+		metrics, err := s.FetchUnpublishedMetrics(context.Background(), 10)
+		require.NoError(t, err)
+		require.Len(t, metrics, 2)
+
+		for _, metric := range metrics {
+			require.Equal(t, telemetry.GitSummaryType, metric.Type)
+			require.Equal(t, 1, metric.Version)
+			require.False(t, metric.Timestamp.IsZero())
 
-		require.Equal(t, telemetry.StateHashType, metrics[1].Type)
-		require.Equal(t, 1, metrics[0].Version)
-		require.Equal(t, stateHash{}.ChainID(), metrics[1].Payload.(*telemetry.StateHashMetric).ChainID)
-		require.Equal(t, stateHash{}.BlockNumber(), metrics[1].Payload.(*telemetry.StateHashMetric).BlockNumber)
-		require.Equal(t, stateHash{}.Hash(), metrics[1].Payload.(*telemetry.StateHashMetric).Hash)
+			gv := metric.Payload.(*telemetry.GitSummaryMetric)
+			require.Equal(t, gitSummary{}.GetGitCommit(), gv.GitCommit)
+			require.Equal(t, gitSummary{}.GetGitBranch(), gv.GitBranch)
+			require.Equal(t, gitSummary{}.GetGitState(), gv.GitState)
+			require.Equal(t, gitSummary{}.GetGitSummary(), gv.GitSummary)
+			require.Equal(t, gitSummary{}.GetBuildDate(), gv.BuildDate)
+			require.Equal(t, gitSummary{}.GetBinaryVersion(), gv.BinaryVersion)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusOK)
+		}))
+		defer ts.Close()
+
+		exporter, err := publisher.NewHTTPExporter(ts.URL, "")
+		require.NoError(t, err)
+		nodeID := strings.Replace(uuid.NewString(), "-", "", -1)
+		p := publisher.NewPublisher(s, exporter, nodeID, time.Second)
+		p.Start()
+
+		require.Eventually(t, func() bool {
+			metrics, err = s.FetchUnpublishedMetrics(context.Background(), 2)
+			require.NoError(t, err)
+			return len(metrics) == 0
+		}, 5*time.Second, time.Second)
+
+		p.Close()
+	})
+	t.Run("chains stack summary", func(t *testing.T) {
+		// collect two mocked chainsStackSummary metrics
+		chainsStackSummaryMetrics := [2]chainsStackSummary{
+			map[tableland.ChainID]int64{1: 10, 2: 20},
+			map[tableland.ChainID]int64{1: 11, 2: 21},
+		}
+		require.NoError(t, telemetry.Collect(context.Background(), chainsStackSummaryMetrics[0]))
+		require.NoError(t, telemetry.Collect(context.Background(), chainsStackSummaryMetrics[1]))
+
+		metrics, err := s.FetchUnpublishedMetrics(context.Background(), 10)
+		require.NoError(t, err)
+		require.Len(t, metrics, 2)
+
+		for i, metric := range metrics {
+			require.Equal(t, telemetry.ChainStacksSummaryType, metric.Type)
+			require.Equal(t, 1, metric.Version)
+			require.False(t, metric.Timestamp.IsZero())
+
+			css := metric.Payload.(*telemetry.ChainStacksMetric)
+			require.Equal(t, chainsStackSummaryMetrics[i].GetLastProcessedBlockNumber(), css.LastProcessedBlockNumbers)
+		}
 
 		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			w.WriteHeader(http.StatusOK)
@@ -64,6 +157,19 @@ func TestCollectAndFetchAndPublish(t *testing.T) {
 	})
 }
 
+type chainsStackSummary map[tableland.ChainID]int64
+
+func (css chainsStackSummary) GetLastProcessedBlockNumber() map[tableland.ChainID]int64 { return css }
+
+type gitSummary struct{}
+
+func (gs gitSummary) GetGitCommit() string     { return "fakeGitCommit" }
+func (gs gitSummary) GetGitBranch() string     { return "fakeGitBranch" }
+func (gs gitSummary) GetGitState() string      { return "fakeGitState" }
+func (gs gitSummary) GetGitSummary() string    { return "fakeGitSummary" }
+func (gs gitSummary) GetBuildDate() string     { return "fakeGitDate" }
+func (gs gitSummary) GetBinaryVersion() string { return "fakeBinaryVersion" }
+
 type stateHash struct{}
 
 func (h stateHash) ChainID() int64 {
diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go
index f312094..e9d1bab 100644
--- a/pkg/telemetry/telemetry_test.go
+++ b/pkg/telemetry/telemetry_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/require"
+	"github.com/textileio/go-tableland/internal/tableland"
 )
 
 func TestCollectWithoutStore(t *testing.T) {
@@ -22,6 +23,25 @@ func TestCollectMockedtStore(t *testing.T) {
 		require.NoError(t, err)
 		require.True(t, s.called)
 	})
+	t.Run("git summary", func(t *testing.T) {
+		s := &store{}
+		metricStore = s
+
+		require.False(t, s.called)
+		err := Collect(context.Background(), gitSummary{})
+		require.NoError(t, err)
+		require.True(t, s.called)
+	})
+	t.Run("chains stack summary", func(t *testing.T) {
+		s := &store{}
+		metricStore = s
+
+		require.False(t, s.called)
+
+		err := Collect(context.Background(), chainsStackSummary(map[tableland.ChainID]int64{1: 10, 2: 20}))
+		require.NoError(t, err)
+		require.True(t, s.called)
+	})
 }
 
 func TestCollectUnknownMetric(t *testing.T) {
@@ -33,19 +53,24 @@ func TestCollectUnknownMetric(t *testing.T) {
 	require.ErrorContains(t, err, "unknown metric")
 }
 
-type stateHash struct{}
+type chainsStackSummary map[tableland.ChainID]int64
 
-func (h stateHash) ChainID() int64 {
-	return 1
-}
+func (css chainsStackSummary) GetLastProcessedBlockNumber() map[tableland.ChainID]int64 { return css }
 
-func (h stateHash) BlockNumber() int64 {
-	return 1
-}
+type gitSummary struct{}
 
-func (h stateHash) Hash() string {
-	return "abcdefgh"
-}
+func (gs gitSummary) GetGitCommit() string     { return "fakeGitCommit" }
+func (gs gitSummary) GetGitBranch() string     { return "fakeGitBranch" }
+func (gs gitSummary) GetGitState() string      { return "fakeGitState" }
+func (gs gitSummary) GetGitSummary() string    { return "fakeGitSummary" }
+func (gs gitSummary) GetBuildDate() string     { return "fakeGitDate" }
+func (gs gitSummary) GetBinaryVersion() string { return "fakeBinaryVersion" }
+
+type stateHash struct{}
+
+func (h stateHash) ChainID() int64     { return 1 }
+func (h stateHash) BlockNumber() int64 { return 1 }
+func (h stateHash) Hash() string       { return "abcdefgh" }
 
 type store struct {
 	called bool