Skip to content

Commit 022ecd8

Browse files
committed
feat: metrics RPC endpoint and parser
1 parent 2b99566 commit 022ecd8

File tree

5 files changed

+261
-12
lines changed

5 files changed

+261
-12
lines changed

internal/accounts.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@ type Account struct {
2222
LastModified int
2323
}
2424

25-
// AccountsFromParticipationKeys maps an array of api.ParticipationKey to a keyed map of Account
26-
func AccountsFromState(state *StateModel) map[string]Account {
25+
type Accounts map[string]Account
26+
27+
// AccountsFromState maps the StateModel to a keyed map of Account
28+
func AccountsFromState(state *StateModel) Accounts {
2729
values := make(map[string]Account)
2830
if state == nil || state.ParticipationKeys == nil {
2931
return values
3032
}
3133
for _, key := range *state.ParticipationKeys {
3234
val, ok := values[key.Address]
3335
if !ok {
34-
36+
// TODO: update from State
3537
values[key.Address] = Account{
3638
Address: key.Address,
3739
Status: "NA",
@@ -41,7 +43,6 @@ func AccountsFromState(state *StateModel) map[string]Account {
4143
}
4244
} else {
4345
val.Keys++
44-
//val.
4546
values[key.Address] = val
4647
}
4748
}

internal/metrics.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,70 @@
11
package internal
22

3-
import "time"
3+
import (
4+
"context"
5+
"errors"
6+
"github.com/algorandfoundation/hack-tui/api"
7+
"regexp"
8+
"strconv"
9+
"strings"
10+
"time"
11+
)
412

513
type MetricsModel struct {
14+
Enabled bool
615
Window int
716
RoundTime time.Duration
817
TPS float64
918
RX int
1019
TX int
1120
}
21+
22+
type MetricsResponse map[string]int
23+
24+
func parseMetricsContent(content string) (MetricsResponse, error) {
25+
var err error
26+
result := MetricsResponse{}
27+
28+
// Validate the Content
29+
var isValid bool
30+
isValid, err = regexp.MatchString(`^#`, content)
31+
isValid = isValid && err == nil && content != ""
32+
if !isValid {
33+
return nil, errors.New("invalid metrics content")
34+
}
35+
36+
// Regex for Metrics Format,
37+
// selects all content that does not start with # in multiline mode
38+
re := regexp.MustCompile(`(?m)^[^#].*`)
39+
rows := re.FindAllString(content, -1)
40+
41+
// Add the strings to the map
42+
for _, row := range rows {
43+
var value int
44+
keyValue := strings.Split(row, " ")
45+
value, err = strconv.Atoi(keyValue[1])
46+
result[keyValue[0]] = value
47+
}
48+
49+
// Handle any error results
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
// Give the user what they asked for
55+
return result, nil
56+
}
57+
58+
// GetMetrics parses the /metrics endpoint from algod into a map
59+
func GetMetrics(ctx context.Context, client *api.ClientWithResponses) (MetricsResponse, error) {
60+
res, err := client.MetricsWithResponse(ctx)
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
if res.StatusCode() != 200 {
66+
return nil, errors.New("invalid status code")
67+
}
68+
69+
return parseMetricsContent(string(res.Body))
70+
}

internal/metrics_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"github.com/algorandfoundation/hack-tui/api"
6+
"github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider"
7+
"strconv"
8+
"testing"
9+
)
10+
11+
func Test_GetMetrics(t *testing.T) {
12+
// Setup elevated client
13+
apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
client, err := api.NewClientWithResponses("http://localhost:4001", api.WithRequestEditorFn(apiToken.Intercept))
18+
19+
metrics, err := GetMetrics(context.Background(), client)
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
24+
// TODO: ensure localnet is running before tests
25+
metrics, err = GetMetrics(context.Background(), client)
26+
if err != nil {
27+
t.Fatal(err)
28+
}
29+
30+
if metrics["algod_agreement_dropped"] != 0 {
31+
t.Fatal(strconv.Itoa(metrics["algod_agreement_dropped"]) + " is not zero")
32+
}
33+
}
34+
35+
func Test_parseMetrics(t *testing.T) {
36+
content := `# HELP algod_telemetry_drops_total telemetry messages dropped due to full queues
37+
# TYPE algod_telemetry_drops_total counter
38+
algod_telemetry_drops_total 0
39+
# HELP algod_telemetry_errs_total telemetry messages dropped due to server error
40+
# TYPE algod_telemetry_errs_total counter
41+
algod_telemetry_errs_total 0
42+
# HELP algod_ram_usage number of bytes runtime.ReadMemStats().HeapInuse
43+
# TYPE algod_ram_usage gauge
44+
algod_ram_usage 0
45+
# HELP algod_crypto_vrf_generate_total Total number of calls to GenerateVRFSecrets
46+
# TYPE algod_crypto_vrf_generate_total counter
47+
algod_crypto_vrf_generate_total 0
48+
# HELP algod_crypto_vrf_prove_total Total number of calls to VRFSecrets.Prove
49+
# TYPE algod_crypto_vrf_prove_total counter
50+
algod_crypto_vrf_prove_total 0
51+
# HELP algod_crypto_vrf_hash_total Total number of calls to VRFProof.Hash
52+
# TYPE algod_crypto_vrf_hash_total counter
53+
algod_crypto_vrf_hash_total 0
54+
`
55+
metrics, err := parseMetricsContent(content)
56+
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
61+
if metrics["algod_telemetry_drops_total"] != 0 {
62+
t.Fatal(strconv.Itoa(metrics["algod_telemetry_drops_total"]) + " is not 0")
63+
}
64+
65+
content = `INVALID`
66+
_, err = parseMetricsContent(content)
67+
if err == nil {
68+
t.Fatal(err)
69+
}
70+
71+
content = `# HELP algod_telemetry_drops_total telemetry messages dropped due to full queues
72+
# TYPE algod_telemetry_drops_total counter
73+
algod_telemetry_drops_total NAN`
74+
_, err = parseMetricsContent(content)
75+
if err == nil {
76+
t.Fatal(err)
77+
}
78+
}

internal/state.go

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ type StateModel struct {
1212
Metrics MetricsModel
1313
Accounts map[string]Account
1414
ParticipationKeys *[]api.ParticipationKey
15+
// TODO: handle contexts instead of adding it to state
16+
Admin bool
17+
Watching bool
1518
}
1619

1720
func getAverage(data []float64) float64 {
@@ -30,7 +33,10 @@ func getAverageDuration(timings []time.Duration) time.Duration {
3033
return time.Duration(avg * float64(time.Second))
3134
}
3235

36+
// TODO: allow context to handle loop
3337
func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Context, client *api.ClientWithResponses) {
38+
s.Watching = true
39+
3440
err := s.Status.Fetch(ctx, client)
3541
if err != nil {
3642
cb(nil, err)
@@ -44,6 +50,9 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co
4450
txns := make([]float64, 0)
4551

4652
for {
53+
if !s.Watching {
54+
break
55+
}
4756
// Collect Time of Round
4857
startTime := time.Now()
4958
status, err := client.WaitForBlockWithResponse(ctx, int(lastRound))
@@ -62,13 +71,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co
6271
s.Status.LastRound = uint64(status.JSON200.LastRound)
6372

6473
// Fetch Keys
65-
s.ParticipationKeys, err = GetPartKeys(ctx, client)
66-
if err != nil {
67-
cb(nil, err)
68-
}
69-
70-
// Get Accounts
71-
s.Accounts = AccountsFromState(s)
74+
s.UpdateKeys(ctx, client)
7275

7376
// Fetch Block
7477
var format api.GetBlockParamsFormat = "json"
@@ -93,6 +96,9 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co
9396
s.Metrics.Window = len(timings)
9497
s.Metrics.TPS = getAverage(txns)
9598

99+
// Fetch RX/TX
100+
s.UpdateMetrics(ctx, client, timings, txns)
101+
96102
// Trim data
97103
if len(timings) >= 100 {
98104
timings = timings[1:]
@@ -103,3 +109,49 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co
103109
cb(s, nil)
104110
}
105111
}
112+
113+
func (s *StateModel) Stop() {
114+
s.Watching = false
115+
}
116+
117+
func (s *StateModel) UpdateMetrics(
118+
ctx context.Context,
119+
client *api.ClientWithResponses,
120+
timings []time.Duration,
121+
txns []float64,
122+
) {
123+
if s == nil {
124+
panic("StateModel is nil while UpdateMetrics is called")
125+
}
126+
// Set Metrics
127+
s.Metrics.RoundTime = getAverageDuration(timings)
128+
s.Metrics.Window = len(timings)
129+
s.Metrics.TPS = getAverage(txns)
130+
131+
// Fetch RX/TX
132+
res, err := GetMetrics(ctx, client)
133+
if err != nil {
134+
s.Metrics.Enabled = false
135+
}
136+
if err == nil {
137+
s.Metrics.Enabled = true
138+
s.Metrics.TX = res["algod_network_sent_bytes_total"]
139+
s.Metrics.RX = res["algod_network_received_bytes_total"]
140+
}
141+
}
142+
143+
func (s *StateModel) UpdateAccounts() {
144+
s.Accounts = AccountsFromState(s)
145+
}
146+
147+
func (s *StateModel) UpdateKeys(ctx context.Context, client *api.ClientWithResponses) {
148+
var err error
149+
s.ParticipationKeys, err = GetPartKeys(ctx, client)
150+
if err != nil {
151+
s.Admin = false
152+
}
153+
if err == nil {
154+
s.Admin = true
155+
s.UpdateAccounts()
156+
}
157+
}

internal/state_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"github.com/algorandfoundation/hack-tui/api"
6+
"github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider"
7+
"testing"
8+
"time"
9+
)
10+
11+
func Test_StateModel(t *testing.T) {
12+
// Setup elevated client
13+
apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept))
18+
19+
state := StateModel{
20+
Watching: true,
21+
Status: StatusModel{
22+
LastRound: 1337,
23+
NeedsUpdate: true,
24+
State: "SYNCING",
25+
},
26+
Metrics: MetricsModel{
27+
RoundTime: 0,
28+
TX: 0,
29+
RX: 0,
30+
TPS: 0,
31+
},
32+
}
33+
count := 0
34+
go state.Watch(func(model *StateModel, err error) {
35+
if err != nil || model == nil {
36+
t.Error("Failed")
37+
return
38+
}
39+
count++
40+
}, context.Background(), client)
41+
time.Sleep(5 * time.Second)
42+
// Stop the watcher
43+
state.Stop()
44+
if count == 0 {
45+
t.Fatal("Did not receive any updates")
46+
}
47+
if state.Status.LastRound <= 0 {
48+
t.Fatal("LastRound is stale")
49+
}
50+
t.Log(
51+
"Watching: ", state.Watching,
52+
"LastRound: ", state.Status.LastRound,
53+
"NeedsUpdate: ", state.Status.NeedsUpdate,
54+
"State: ", state.Status.State,
55+
"RoundTime: ", state.Metrics.RoundTime,
56+
"RX: ", state.Metrics.RX,
57+
"TX: ", state.Metrics.TX,
58+
)
59+
}

0 commit comments

Comments
 (0)