2020-07-03 14:52:40 +00:00
|
|
|
package stats
|
2019-10-12 00:13:16 +00:00
|
|
|
|
|
|
|
import (
|
2020-06-15 20:34:48 +00:00
|
|
|
"bytes"
|
2019-10-12 00:13:16 +00:00
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
2020-09-10 14:26:34 +00:00
|
|
|
"math"
|
2019-10-18 11:51:35 +00:00
|
|
|
"math/big"
|
2019-10-12 00:13:16 +00:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2019-12-19 20:13:17 +00:00
|
|
|
"github.com/filecoin-project/go-address"
|
2019-10-18 11:51:35 +00:00
|
|
|
"github.com/filecoin-project/lotus/api"
|
|
|
|
"github.com/filecoin-project/lotus/build"
|
2020-09-10 14:26:34 +00:00
|
|
|
"github.com/filecoin-project/lotus/chain/store"
|
2019-10-18 11:51:35 +00:00
|
|
|
"github.com/filecoin-project/lotus/chain/types"
|
2020-02-23 07:49:15 +00:00
|
|
|
"github.com/filecoin-project/specs-actors/actors/builtin"
|
2020-06-15 20:34:48 +00:00
|
|
|
"github.com/filecoin-project/specs-actors/actors/builtin/power"
|
|
|
|
"github.com/filecoin-project/specs-actors/actors/util/adt"
|
2020-08-13 14:48:29 +00:00
|
|
|
"golang.org/x/xerrors"
|
2020-06-15 20:34:48 +00:00
|
|
|
|
2019-12-12 16:42:49 +00:00
|
|
|
"github.com/ipfs/go-cid"
|
2019-12-11 00:53:18 +00:00
|
|
|
"github.com/multiformats/go-multihash"
|
2019-10-12 00:13:16 +00:00
|
|
|
|
2020-06-15 20:34:48 +00:00
|
|
|
cbg "github.com/whyrusleeping/cbor-gen"
|
|
|
|
|
2019-10-12 00:13:16 +00:00
|
|
|
_ "github.com/influxdata/influxdb1-client"
|
|
|
|
models "github.com/influxdata/influxdb1-client/models"
|
|
|
|
client "github.com/influxdata/influxdb1-client/v2"
|
2020-07-03 14:52:40 +00:00
|
|
|
|
|
|
|
logging "github.com/ipfs/go-log/v2"
|
2019-10-12 00:13:16 +00:00
|
|
|
)
|
|
|
|
|
2020-07-03 14:52:40 +00:00
|
|
|
var log = logging.Logger("stats")
|
|
|
|
|
2019-10-12 00:13:16 +00:00
|
|
|
type PointList struct {
|
|
|
|
points []models.Point
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewPointList() *PointList {
|
|
|
|
return &PointList{}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pl *PointList) AddPoint(p models.Point) {
|
|
|
|
pl.points = append(pl.points, p)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pl *PointList) Points() []models.Point {
|
|
|
|
return pl.points
|
|
|
|
}
|
|
|
|
|
|
|
|
type InfluxWriteQueue struct {
|
2020-06-02 14:29:39 +00:00
|
|
|
ch chan client.BatchPoints
|
2019-10-12 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func NewInfluxWriteQueue(ctx context.Context, influx client.Client) *InfluxWriteQueue {
|
|
|
|
ch := make(chan client.BatchPoints, 128)
|
|
|
|
|
|
|
|
maxRetries := 10
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
main:
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case batch := <-ch:
|
|
|
|
for i := 0; i < maxRetries; i++ {
|
|
|
|
if err := influx.Write(batch); err != nil {
|
2019-12-19 14:58:26 +00:00
|
|
|
log.Warnw("Failed to write batch", "error", err)
|
2020-07-10 14:43:14 +00:00
|
|
|
build.Clock.Sleep(15 * time.Second)
|
2019-10-12 00:13:16 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
continue main
|
|
|
|
}
|
|
|
|
|
2019-12-19 14:58:26 +00:00
|
|
|
log.Error("Dropping batch due to failure to write")
|
2019-10-12 00:13:16 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
return &InfluxWriteQueue{
|
|
|
|
ch: ch,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *InfluxWriteQueue) AddBatch(bp client.BatchPoints) {
|
|
|
|
i.ch <- bp
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *InfluxWriteQueue) Close() {
|
|
|
|
close(i.ch)
|
|
|
|
}
|
|
|
|
|
|
|
|
func InfluxClient(addr, user, pass string) (client.Client, error) {
|
|
|
|
return client.NewHTTPClient(client.HTTPConfig{
|
|
|
|
Addr: addr,
|
|
|
|
Username: user,
|
|
|
|
Password: pass,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func InfluxNewBatch() (client.BatchPoints, error) {
|
|
|
|
return client.NewBatchPoints(client.BatchPointsConfig{})
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewPoint(name string, value interface{}) models.Point {
|
2020-08-13 14:55:12 +00:00
|
|
|
pt, _ := models.NewPoint(name, models.Tags{},
|
|
|
|
map[string]interface{}{"value": value}, build.Clock.Now().UTC())
|
2019-10-12 00:13:16 +00:00
|
|
|
return pt
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewPointFrom(p models.Point) *client.Point {
|
|
|
|
return client.NewPointFrom(p)
|
|
|
|
}
|
|
|
|
|
|
|
|
func RecordTipsetPoints(ctx context.Context, api api.FullNode, pl *PointList, tipset *types.TipSet) error {
|
|
|
|
cids := []string{}
|
|
|
|
for _, cid := range tipset.Cids() {
|
|
|
|
cids = append(cids, cid.String())
|
|
|
|
}
|
|
|
|
|
|
|
|
p := NewPoint("chain.height", int64(tipset.Height()))
|
|
|
|
p.AddTag("tipset", strings.Join(cids, " "))
|
|
|
|
pl.AddPoint(p)
|
|
|
|
|
|
|
|
p = NewPoint("chain.block_count", len(cids))
|
|
|
|
pl.AddPoint(p)
|
|
|
|
|
|
|
|
tsTime := time.Unix(int64(tipset.MinTimestamp()), int64(0))
|
|
|
|
p = NewPoint("chain.blocktime", tsTime.Unix())
|
|
|
|
pl.AddPoint(p)
|
|
|
|
|
2020-08-13 14:48:29 +00:00
|
|
|
totalGasLimit := int64(0)
|
2020-08-25 14:57:14 +00:00
|
|
|
totalUniqGasLimit := int64(0)
|
|
|
|
seen := make(map[cid.Cid]struct{})
|
2019-10-12 00:13:16 +00:00
|
|
|
for _, blockheader := range tipset.Blocks() {
|
|
|
|
bs, err := blockheader.Serialize()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2020-07-21 17:00:29 +00:00
|
|
|
p := NewPoint("chain.election", blockheader.ElectionProof.WinCount)
|
2019-12-10 23:36:40 +00:00
|
|
|
p.AddTag("miner", blockheader.Miner.String())
|
|
|
|
pl.AddPoint(p)
|
2019-10-12 00:13:16 +00:00
|
|
|
|
2019-12-10 23:36:40 +00:00
|
|
|
p = NewPoint("chain.blockheader_size", len(bs))
|
2019-10-12 00:13:16 +00:00
|
|
|
pl.AddPoint(p)
|
2020-08-13 14:48:29 +00:00
|
|
|
|
|
|
|
msgs, err := api.ChainGetBlockMessages(ctx, blockheader.Cid())
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("ChainGetBlockMessages failed: %w", msgs)
|
|
|
|
}
|
|
|
|
for _, m := range msgs.BlsMessages {
|
2020-08-25 14:57:14 +00:00
|
|
|
c := m.Cid()
|
2020-08-13 14:48:29 +00:00
|
|
|
totalGasLimit += m.GasLimit
|
2020-08-25 14:57:14 +00:00
|
|
|
if _, ok := seen[c]; !ok {
|
|
|
|
totalUniqGasLimit += m.GasLimit
|
|
|
|
seen[c] = struct{}{}
|
|
|
|
}
|
2020-08-13 14:48:29 +00:00
|
|
|
}
|
|
|
|
for _, m := range msgs.SecpkMessages {
|
2020-08-25 14:57:14 +00:00
|
|
|
c := m.Cid()
|
2020-08-13 14:48:29 +00:00
|
|
|
totalGasLimit += m.Message.GasLimit
|
2020-08-25 14:57:14 +00:00
|
|
|
if _, ok := seen[c]; !ok {
|
|
|
|
totalUniqGasLimit += m.Message.GasLimit
|
|
|
|
seen[c] = struct{}{}
|
|
|
|
}
|
2020-08-13 14:48:29 +00:00
|
|
|
}
|
2019-10-12 00:13:16 +00:00
|
|
|
}
|
2020-08-13 14:48:29 +00:00
|
|
|
p = NewPoint("chain.gas_limit_total", totalGasLimit)
|
|
|
|
pl.AddPoint(p)
|
2020-08-25 14:57:14 +00:00
|
|
|
p = NewPoint("chain.gas_limit_uniq_total", totalUniqGasLimit)
|
|
|
|
pl.AddPoint(p)
|
2019-10-12 00:13:16 +00:00
|
|
|
|
2020-09-10 14:26:34 +00:00
|
|
|
{
|
|
|
|
baseFeeIn := tipset.Blocks()[0].ParentBaseFee
|
|
|
|
newBaseFee := store.ComputeNextBaseFee(baseFeeIn, totalUniqGasLimit, len(tipset.Blocks()), tipset.Height())
|
|
|
|
|
|
|
|
baseFeeRat := new(big.Rat).SetFrac(newBaseFee.Int, new(big.Int).SetUint64(build.FilecoinPrecision))
|
|
|
|
baseFeeFloat, _ := baseFeeRat.Float64()
|
|
|
|
p = NewPoint("chain.basefee", baseFeeFloat)
|
|
|
|
pl.AddPoint(p)
|
|
|
|
|
|
|
|
baseFeeChange := new(big.Rat).SetFrac(newBaseFee.Int, baseFeeIn.Int)
|
|
|
|
baseFeeChangeF, _ := baseFeeChange.Float64()
|
|
|
|
p = NewPoint("chain.basefee_change_log", math.Log(baseFeeChangeF)/math.Log(1.125))
|
|
|
|
pl.AddPoint(p)
|
|
|
|
}
|
|
|
|
{
|
|
|
|
blks := len(cids)
|
|
|
|
p = NewPoint("chain.gas_fill_ratio", float64(totalGasLimit)/float64(blks*build.BlockGasTarget))
|
|
|
|
pl.AddPoint(p)
|
|
|
|
p = NewPoint("chain.gas_capacity_ratio", float64(totalUniqGasLimit)/float64(blks*build.BlockGasTarget))
|
|
|
|
pl.AddPoint(p)
|
|
|
|
p = NewPoint("chain.gas_waste_ratio", float64(totalGasLimit-totalUniqGasLimit)/float64(blks*build.BlockGasTarget))
|
|
|
|
pl.AddPoint(p)
|
|
|
|
}
|
|
|
|
|
2019-10-12 00:13:16 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-06-15 20:34:48 +00:00
|
|
|
type apiIpldStore struct {
|
|
|
|
ctx context.Context
|
|
|
|
api api.FullNode
|
|
|
|
}
|
|
|
|
|
|
|
|
func (ht *apiIpldStore) Context() context.Context {
|
|
|
|
return ht.ctx
|
|
|
|
}
|
|
|
|
|
|
|
|
func (ht *apiIpldStore) Get(ctx context.Context, c cid.Cid, out interface{}) error {
|
|
|
|
raw, err := ht.api.ChainReadObj(ctx, c)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
cu, ok := out.(cbg.CBORUnmarshaler)
|
|
|
|
if ok {
|
|
|
|
if err := cu.UnmarshalCBOR(bytes.NewReader(raw)); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return fmt.Errorf("Object does not implement CBORUnmarshaler")
|
|
|
|
}
|
|
|
|
|
|
|
|
func (ht *apiIpldStore) Put(ctx context.Context, v interface{}) (cid.Cid, error) {
|
|
|
|
return cid.Undef, fmt.Errorf("Put is not implemented on apiIpldStore")
|
|
|
|
}
|
|
|
|
|
2019-10-12 00:13:16 +00:00
|
|
|
func RecordTipsetStatePoints(ctx context.Context, api api.FullNode, pl *PointList, tipset *types.TipSet) error {
|
2019-10-18 11:51:35 +00:00
|
|
|
attoFil := types.NewInt(build.FilecoinPrecision).Int
|
|
|
|
|
2020-07-10 15:52:44 +00:00
|
|
|
//TODO: StatePledgeCollateral API is not implemented and is commented out - re-enable this block once the API is implemented again.
|
|
|
|
//pc, err := api.StatePledgeCollateral(ctx, tipset.Key())
|
|
|
|
//if err != nil {
|
|
|
|
//return err
|
|
|
|
//}
|
|
|
|
|
|
|
|
//pcFil := new(big.Rat).SetFrac(pc.Int, attoFil)
|
|
|
|
//pcFilFloat, _ := pcFil.Float64()
|
|
|
|
//p := NewPoint("chain.pledge_collateral", pcFilFloat)
|
|
|
|
//pl.AddPoint(p)
|
2019-10-18 11:51:35 +00:00
|
|
|
|
2020-02-23 07:49:15 +00:00
|
|
|
netBal, err := api.WalletBalance(ctx, builtin.RewardActorAddr)
|
2019-10-18 11:51:35 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
netBalFil := new(big.Rat).SetFrac(netBal.Int, attoFil)
|
|
|
|
netBalFilFloat, _ := netBalFil.Float64()
|
2020-07-10 15:52:44 +00:00
|
|
|
p := NewPoint("network.balance", netBalFilFloat)
|
2019-10-12 00:13:16 +00:00
|
|
|
pl.AddPoint(p)
|
|
|
|
|
2020-06-15 20:34:48 +00:00
|
|
|
totalPower, err := api.StateMinerPower(ctx, address.Address{}, tipset.Key())
|
2019-10-12 00:13:16 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2020-06-15 20:34:48 +00:00
|
|
|
p = NewPoint("chain.power", totalPower.TotalPower.QualityAdjPower.Int64())
|
2019-10-12 00:13:16 +00:00
|
|
|
pl.AddPoint(p)
|
|
|
|
|
2020-06-15 20:34:48 +00:00
|
|
|
powerActor, err := api.StateGetActor(ctx, builtin.StoragePowerActorAddr, tipset.Key())
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
powerRaw, err := api.ChainReadObj(ctx, powerActor.Head)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
var powerActorState power.State
|
|
|
|
|
|
|
|
if err := powerActorState.UnmarshalCBOR(bytes.NewReader(powerRaw)); err != nil {
|
|
|
|
return fmt.Errorf("failed to unmarshal power actor state: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
s := &apiIpldStore{ctx, api}
|
|
|
|
mp, err := adt.AsMap(s, powerActorState.Claims)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2020-08-20 04:49:10 +00:00
|
|
|
var claim power.Claim
|
|
|
|
err = mp.ForEach(&claim, func(key string) error {
|
2020-06-15 20:34:48 +00:00
|
|
|
addr, err := address.NewFromBytes([]byte(key))
|
2019-10-12 00:13:16 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2020-06-15 20:34:48 +00:00
|
|
|
if claim.QualityAdjPower.Int64() == 0 {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
p = NewPoint("chain.miner_power", claim.QualityAdjPower.Int64())
|
|
|
|
p.AddTag("miner", addr.String())
|
2019-10-12 00:13:16 +00:00
|
|
|
pl.AddPoint(p)
|
2020-06-15 20:34:48 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return err
|
2019-10-12 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-12-12 16:42:49 +00:00
|
|
|
type msgTag struct {
|
|
|
|
actor string
|
|
|
|
method uint64
|
|
|
|
exitcode uint8
|
|
|
|
}
|
|
|
|
|
2019-10-12 00:13:16 +00:00
|
|
|
func RecordTipsetMessagesPoints(ctx context.Context, api api.FullNode, pl *PointList, tipset *types.TipSet) error {
|
|
|
|
cids := tipset.Cids()
|
|
|
|
if len(cids) == 0 {
|
|
|
|
return fmt.Errorf("no cids in tipset")
|
|
|
|
}
|
|
|
|
|
|
|
|
msgs, err := api.ChainGetParentMessages(ctx, cids[0])
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
recp, err := api.ChainGetParentReceipts(ctx, cids[0])
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-12-12 16:42:49 +00:00
|
|
|
msgn := make(map[msgTag][]cid.Cid)
|
|
|
|
|
2020-08-13 14:55:12 +00:00
|
|
|
totalGasUsed := int64(0)
|
|
|
|
for _, r := range recp {
|
|
|
|
totalGasUsed += r.GasUsed
|
|
|
|
}
|
|
|
|
p := NewPoint("chain.gas_used_total", totalGasUsed)
|
|
|
|
pl.AddPoint(p)
|
|
|
|
|
2019-10-12 00:13:16 +00:00
|
|
|
for i, msg := range msgs {
|
2020-08-06 21:08:42 +00:00
|
|
|
// FIXME: use float so this doesn't overflow
|
2020-08-20 04:49:10 +00:00
|
|
|
// FIXME: this doesn't work as time points get overridden
|
2020-08-06 21:08:42 +00:00
|
|
|
p := NewPoint("chain.message_gaspremium", msg.Message.GasPremium.Int64())
|
|
|
|
pl.AddPoint(p)
|
|
|
|
p = NewPoint("chain.message_gasfeecap", msg.Message.GasFeeCap.Int64())
|
2019-10-12 00:13:16 +00:00
|
|
|
pl.AddPoint(p)
|
|
|
|
|
|
|
|
bs, err := msg.Message.Serialize()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
p = NewPoint("chain.message_size", len(bs))
|
|
|
|
pl.AddPoint(p)
|
|
|
|
|
2020-02-11 23:29:45 +00:00
|
|
|
actor, err := api.StateGetActor(ctx, msg.Message.To, tipset.Key())
|
2019-10-12 00:13:16 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-12-11 00:53:18 +00:00
|
|
|
dm, err := multihash.Decode(actor.Code.Hash())
|
|
|
|
if err != nil {
|
|
|
|
continue
|
|
|
|
}
|
2019-12-12 16:42:49 +00:00
|
|
|
tag := msgTag{
|
|
|
|
actor: string(dm.Digest),
|
2020-02-23 07:49:15 +00:00
|
|
|
method: uint64(msg.Message.Method),
|
2020-03-03 23:55:57 +00:00
|
|
|
exitcode: uint8(recp[i].ExitCode),
|
2019-12-12 16:42:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
found := false
|
|
|
|
for _, c := range msgn[tag] {
|
|
|
|
if c.Equals(msg.Cid) {
|
|
|
|
found = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !found {
|
|
|
|
msgn[tag] = append(msgn[tag], msg.Cid)
|
|
|
|
}
|
|
|
|
}
|
2019-12-11 00:53:18 +00:00
|
|
|
|
2019-12-12 16:42:49 +00:00
|
|
|
for t, m := range msgn {
|
|
|
|
p := NewPoint("chain.message_count", len(m))
|
|
|
|
p.AddTag("actor", t.actor)
|
|
|
|
p.AddTag("method", fmt.Sprintf("%d", t.method))
|
|
|
|
p.AddTag("exitcode", fmt.Sprintf("%d", t.exitcode))
|
2019-10-12 00:13:16 +00:00
|
|
|
pl.AddPoint(p)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func ResetDatabase(influx client.Client, database string) error {
|
2019-12-19 14:58:26 +00:00
|
|
|
log.Info("Resetting database")
|
2019-10-12 00:13:16 +00:00
|
|
|
q := client.NewQuery(fmt.Sprintf(`DROP DATABASE "%s"; CREATE DATABASE "%s";`, database, database), "", "")
|
|
|
|
_, err := influx.Query(q)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
func GetLastRecordedHeight(influx client.Client, database string) (int64, error) {
|
2019-12-19 14:58:26 +00:00
|
|
|
log.Info("Retrieving last record height")
|
2019-10-12 00:13:16 +00:00
|
|
|
q := client.NewQuery(`SELECT "value" FROM "chain.height" ORDER BY time DESC LIMIT 1`, database, "")
|
|
|
|
res, err := influx.Query(q)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(res.Results) == 0 {
|
|
|
|
return 0, fmt.Errorf("No results found for last recorded height")
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(res.Results[0].Series) == 0 {
|
|
|
|
return 0, fmt.Errorf("No results found for last recorded height")
|
|
|
|
}
|
|
|
|
|
|
|
|
height, err := (res.Results[0].Series[0].Values[0][1].(json.Number)).Int64()
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
2019-12-19 14:58:26 +00:00
|
|
|
log.Infow("Last record height", "height", height)
|
2019-10-12 00:13:16 +00:00
|
|
|
|
|
|
|
return height, nil
|
|
|
|
}
|