diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f81ff2d2d..1e9895952 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,8 @@ jobs: autopilot bus bus/client worker worker/client + flags: | + -japecheck.types=false test: needs: analyze runs-on: ${{ matrix.os }} diff --git a/alerts/prometheus.go b/alerts/prometheus.go new file mode 100644 index 000000000..ca90537c7 --- /dev/null +++ b/alerts/prometheus.go @@ -0,0 +1,24 @@ +package alerts + +import "go.sia.tech/renterd/internal/prometheus" + +// PrometheusMetric implements prometheus.Marshaller. +func (a Alert) PrometheusMetric() (metrics []prometheus.Metric) { + metrics = append(metrics, prometheus.Metric{ + Name: "renterd_alert", + Labels: map[string]any{ + "id": a.ID, + "severity": a.Severity.String(), + "message": a.Message, + "timestamp": a.Timestamp, + }, + Value: 1, + }) + return +} + +// PrometheusMetric implements prometheus.Marshaller. +func (a AlertsResponse) PrometheusMetric() (metrics []prometheus.Metric) { + metrics = prometheus.Slice(a.Alerts).PrometheusMetric() + return +} diff --git a/api/prometheus.go b/api/prometheus.go new file mode 100644 index 000000000..dea6b6145 --- /dev/null +++ b/api/prometheus.go @@ -0,0 +1,868 @@ +package api + +import ( + "fmt" + "strconv" + "time" + + "go.sia.tech/core/types" + "go.sia.tech/renterd/internal/prometheus" +) + +// See https://prometheus.io/docs/practices/naming/ for naming conventions +// on metrics and labels. + +func boolToFloat(x bool) float64 { + if x { + return 1 + } + return 0 +} + +func (c ConsensusState) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_consensus_state_synced", + Value: boolToFloat(c.Synced), + }, + { + Name: "renterd_consensus_state_last_block_time", + Value: float64(time.Time(c.LastBlockTime).Unix()), + }, + { + Name: "renterd_consensus_state_chain_index_height", + Value: float64(c.BlockHeight), + }, + } +} + +func (asr AutopilotStateResponse) PrometheusMetric() (metrics []prometheus.Metric) { + labels := map[string]any{ + "version": asr.Version, + "commit": asr.Commit, + "os": asr.OS, + "build_time": asr.BuildTime.String(), + } + return []prometheus.Metric{ + { + Name: "renterd_autopilot_state_uptimems", + Labels: labels, + Value: float64(asr.UptimeMS), + }, + { + Name: "renterd_autopilot_state_configured", + Labels: labels, + Value: boolToFloat(asr.Configured), + }, + { + Name: "renterd_autopilot_state_migrating", + Labels: labels, + Value: boolToFloat(asr.Migrating), + }, + { + Name: "renterd_autopilot_state_migratinglaststart", + Labels: labels, + Value: float64(time.Time(asr.MigratingLastStart).Unix()), + }, + { + Name: "renterd_autopilot_state_pruning", + Labels: labels, + Value: boolToFloat(asr.Pruning), + }, + { + Name: "renterd_autopilot_state_pruninglaststart", + Labels: labels, + Value: float64(time.Time(asr.PruningLastStart).Unix()), + }, + { + Name: "renterd_autopilot_state_scanning", + Labels: labels, + Value: boolToFloat(asr.Scanning), + }, + { + Name: "renterd_autopilot_state_scanninglaststart", + Labels: labels, + Value: float64(time.Time(asr.ScanningLastStart).Unix()), + }, + } +} + +func (b Bucket) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_bucket", + Labels: map[string]any{ + "name": b.Name, + "publicReadAccess": boolToFloat(b.Policy.PublicReadAccess), + "createdAt": b.CreatedAt.String(), + }, + Value: 1, + }} +} + +func (host Host) PrometheusMetric() (metrics []prometheus.Metric) { + priceTableLabels := map[string]any{ + "net_address": host.NetAddress, + "uid": host.PriceTable.UID.String(), + "expiry": host.PriceTable.Expiry.Local().Format(time.RFC3339), + } + settingsLabels := map[string]any{ + "net_address": host.NetAddress, + "version": host.Settings.Version, + "siamux_port": host.Settings.SiaMuxPort, + } + netAddressLabel := map[string]any{ + "net_address": host.NetAddress, + } + + return []prometheus.Metric{ + // price table + { + Name: "renterd_host_pricetable_validity", + Labels: priceTableLabels, + Value: float64(host.PriceTable.Validity.Milliseconds()), + }, + { + Name: "renterd_host_pricetable_hostblockheight", + Labels: priceTableLabels, + Value: float64(host.PriceTable.HostBlockHeight), + }, + { + Name: "renterd_host_pricetable_updatepricetablecost", + Labels: priceTableLabels, + Value: host.PriceTable.UpdatePriceTableCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_accountbalancecost", + Labels: priceTableLabels, + Value: host.PriceTable.AccountBalanceCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_fundaccountcost", + Labels: priceTableLabels, + Value: host.PriceTable.FundAccountCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_latestrevisioncost", + Labels: priceTableLabels, + Value: host.PriceTable.LatestRevisionCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_subscriptionmemorycost", + Labels: priceTableLabels, + Value: host.PriceTable.SubscriptionMemoryCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_subscriptionnotificationcost", + Labels: priceTableLabels, + Value: host.PriceTable.SubscriptionNotificationCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_initbasecost", + Labels: priceTableLabels, + Value: host.PriceTable.InitBaseCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_memorytimecost", + Labels: priceTableLabels, + Value: host.PriceTable.MemoryTimeCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_downloadbandwidthcost", + Labels: priceTableLabels, + Value: host.PriceTable.DownloadBandwidthCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_uploadbandwidthcost", + Labels: priceTableLabels, + Value: host.PriceTable.UploadBandwidthCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_dropsectorsbasecost", + Labels: priceTableLabels, + Value: host.PriceTable.DropSectorsBaseCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_dropsectorsunitcost", + Labels: priceTableLabels, + Value: host.PriceTable.DropSectorsUnitCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_hassectorbasecost", + Labels: priceTableLabels, + Value: host.PriceTable.HasSectorBaseCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_readbasecost", + Labels: priceTableLabels, + Value: host.PriceTable.ReadBaseCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_readlengthcost", + Labels: priceTableLabels, + Value: host.PriceTable.ReadLengthCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_renewcontractcost", + Labels: priceTableLabels, + Value: host.PriceTable.RenewContractCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_revisionbasecost", + Labels: priceTableLabels, + Value: host.PriceTable.RevisionBaseCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_swapsectorcost", + Labels: priceTableLabels, + Value: host.PriceTable.SwapSectorBaseCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_writebasecost", + Labels: priceTableLabels, + Value: host.PriceTable.WriteBaseCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_writelengthcost", + Labels: priceTableLabels, + Value: host.PriceTable.WriteLengthCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_writestorecost", + Labels: priceTableLabels, + Value: host.PriceTable.WriteStoreCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_txnfeeminrecommended", + Labels: priceTableLabels, + Value: host.PriceTable.TxnFeeMinRecommended.Siacoins(), + }, + { + Name: "renterd_host_pricetable_txnfeemaxrecommended", + Labels: priceTableLabels, + Value: host.PriceTable.TxnFeeMaxRecommended.Siacoins(), + }, + { + Name: "renterd_host_pricetable_contractprice", + Labels: priceTableLabels, + Value: host.PriceTable.ContractPrice.Siacoins(), + }, + { + Name: "renterd_host_pricetable_collateralcost", + Labels: priceTableLabels, + Value: host.PriceTable.CollateralCost.Siacoins(), + }, + { + Name: "renterd_host_pricetable_maxcollateral", + Labels: priceTableLabels, + Value: host.PriceTable.MaxCollateral.Siacoins(), + }, + { + Name: "renterd_host_pricetable_maxduration", + Labels: priceTableLabels, + Value: float64(host.PriceTable.MaxDuration), + }, + { + Name: "renterd_host_pricetable_windowsize", + Labels: priceTableLabels, + Value: float64(host.PriceTable.WindowSize), + }, + + // settings + { + Name: "renterd_host_settings_acceptingcontracts", + Labels: settingsLabels, + Value: boolToFloat(host.Settings.AcceptingContracts), + }, + { + Name: "renterd_host_settings_baserpcprice", + Labels: settingsLabels, + Value: host.Settings.BaseRPCPrice.Siacoins(), + }, + { + Name: "renterd_host_settings_collateral", + Labels: settingsLabels, + Value: host.Settings.Collateral.Siacoins(), + }, + { + Name: "renterd_host_settings_contractprice", + Labels: settingsLabels, + Value: host.Settings.ContractPrice.Siacoins(), + }, + { + Name: "renterd_host_settings_downloadbandwidthprice", + Labels: settingsLabels, + Value: host.Settings.DownloadBandwidthPrice.Siacoins(), + }, + { + Name: "renterd_host_settings_ephemeralaccountexpiry", + Labels: settingsLabels, + Value: float64(host.Settings.EphemeralAccountExpiry.Milliseconds()), + }, + { + Name: "renterd_host_settings_maxcollateral", + Labels: settingsLabels, + Value: host.Settings.MaxCollateral.Siacoins(), + }, + { + Name: "renterd_host_settings_maxdownloadbatchsize", + Labels: settingsLabels, + Value: float64(host.Settings.MaxDownloadBatchSize), + }, + { + Name: "renterd_host_settings_maxduration", + Labels: settingsLabels, + Value: float64(host.Settings.MaxDuration), + }, + { + Name: "renterd_host_settings_maxephemeralaccountbalance", + Labels: settingsLabels, + Value: host.Settings.MaxEphemeralAccountBalance.Siacoins(), + }, + { + Name: "renterd_host_settings_maxrevisebatchsize", + Labels: settingsLabels, + Value: float64(host.Settings.MaxReviseBatchSize), + }, + { + Name: "renterd_host_settings_remainingstorage", + Labels: settingsLabels, + Value: float64(host.Settings.RemainingStorage), + }, + { + Name: "renterd_host_settings_revisionnumber", + Labels: settingsLabels, + Value: float64(host.Settings.RevisionNumber), + }, + { + Name: "renterd_host_settings_sectoraccessprice", + Labels: settingsLabels, + Value: host.Settings.SectorAccessPrice.Siacoins(), + }, + { + Name: "renterd_host_settings_sectorsize", + Labels: settingsLabels, + Value: float64(host.Settings.SectorSize), + }, + { + Name: "renterd_host_settings_storageprice", + Labels: settingsLabels, + Value: host.Settings.StoragePrice.Siacoins(), + }, + { + Name: "renterd_host_settings_totalstorage", + Labels: settingsLabels, + Value: float64(host.Settings.TotalStorage), + }, + { + Name: "renterd_host_settings_uploadbandwidthprice", + Labels: settingsLabels, + Value: host.Settings.UploadBandwidthPrice.Siacoins(), + }, + { + Name: "renterd_host_settings_windowsize", + Labels: settingsLabels, + Value: float64(host.Settings.WindowSize), + }, + + // interactions + { + Name: "renterd_host_scanned", + Labels: netAddressLabel, + Value: 1, + }, + { + Name: "renterd_host_interactions_totalscans", + Labels: netAddressLabel, + Value: float64(host.Interactions.TotalScans), + }, + { + Name: "renterd_host_interactions_lastscansuccess", + Labels: netAddressLabel, + Value: boolToFloat(host.Interactions.LastScanSuccess), + }, + { + Name: "renterd_host_interactions_lostsectors", + Labels: netAddressLabel, + Value: float64(host.Interactions.LostSectors), + }, + { + Name: "renterd_host_interactions_secondtolastscansuccess", + Labels: netAddressLabel, + Value: boolToFloat(host.Interactions.SecondToLastScanSuccess), + }, + { + Name: "renterd_host_interactions_uptime", + Labels: netAddressLabel, + Value: float64(host.Interactions.Uptime.Milliseconds()), + }, + { + Name: "renterd_host_interactions_downtime", + Labels: netAddressLabel, + Value: float64(host.Interactions.Downtime.Milliseconds()), + }, + { + Name: "renterd_host_interactions_successfulinteractions", + Labels: netAddressLabel, + Value: float64(host.Interactions.SuccessfulInteractions), + }, + { + Name: "renterd_host_interactions_failedinteractions", + Labels: netAddressLabel, + Value: float64(host.Interactions.FailedInteractions), + }} +} + +func (c ContractMetadata) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_contract", + Labels: map[string]any{ + "host_ip": c.HostIP, + "state": c.State, + "host_key": c.HostKey.String(), + "siamux_addr": c.SiamuxAddr, + "contract_price": c.ContractPrice.Siacoins(), + }, + Value: c.InitialRenterFunds.Siacoins(), + }} +} + +func (c ContractsPrunableDataResponse) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_prunable_contracts_total", + Value: float64(c.TotalPrunable), + }, + { + Name: "renterd_prunable_contracts_size", + Value: float64(c.TotalSize), + }} +} + +func formatSettingsMetricName(gp GougingParams, name string) (metrics []prometheus.Metric) { + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_consensusstate_synced", name), + Value: boolToFloat(gp.ConsensusState.Synced), + }) + if name == "gouging" { // upload setting already has renterd_upload_currentheight + metrics = append(metrics, prometheus.Metric{ + Name: "renterd_gouging_currentheight", + Labels: map[string]any{ + "last_block_time": gp.ConsensusState.LastBlockTime.String(), + }, + Value: float64(gp.ConsensusState.BlockHeight), + }) + } + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_maxrpcprice", name), + Value: gp.GougingSettings.MaxRPCPrice.Siacoins(), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_maxcontractprice", name), + Value: gp.GougingSettings.MaxContractPrice.Siacoins(), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_maxdownloadprice", name), + Value: gp.GougingSettings.MaxDownloadPrice.Siacoins(), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_maxuploadprice", name), + Value: gp.GougingSettings.MaxUploadPrice.Siacoins(), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_maxstorageprice", name), + Value: gp.GougingSettings.MaxStoragePrice.Siacoins(), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_hostblockheightleeway", name), + Value: float64(gp.GougingSettings.HostBlockHeightLeeway), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_minpricetablevalidity", name), + Value: float64(gp.GougingSettings.MinPriceTableValidity.Milliseconds()), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_minaccountexpiry", name), + Value: float64(gp.GougingSettings.MinAccountExpiry.Milliseconds()), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_minmaxephemeralaccountbalance", name), + Value: gp.GougingSettings.MinMaxEphemeralAccountBalance.Siacoins(), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_settings_migrationsurchargemultiplier", name), + Value: float64(gp.GougingSettings.MigrationSurchargeMultiplier), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_redundancy_settings_minshards", name), + Value: float64(gp.RedundancySettings.MinShards), + }) + metrics = append(metrics, prometheus.Metric{ + Name: fmt.Sprintf("renterd_%s_redundancy_settings_totalshards", name), + Value: float64(gp.RedundancySettings.TotalShards), + }) + return +} + +func (gp GougingParams) PrometheusMetric() (metrics []prometheus.Metric) { + metrics = formatSettingsMetricName(gp, "gouging") + return +} + +func (up UploadParams) PrometheusMetric() (metrics []prometheus.Metric) { + metrics = formatSettingsMetricName(up.GougingParams, "upload") + metrics = append(metrics, prometheus.Metric{ + Name: "renterd_upload_currentheight", + Labels: map[string]any{ + "contract_set": up.ContractSet, + "upload_packing": boolToFloat(up.UploadPacking), + }, + Value: float64(up.CurrentHeight), + }) + return +} + +// SlabBuffersResp represents multiple `SlabBuffer`s. Its prometheus encoding +// is a summary of the count and average size of slab buffers. +type SlabBuffersResp []SlabBuffer + +func (sb SlabBuffersResp) PrometheusMetric() (metrics []prometheus.Metric) { + totalSize := int64(0) + for _, buffer := range sb { + totalSize += buffer.Size + } + return []prometheus.Metric{ + { + Name: "renterd_slabbuffers_totalsize", + Value: float64(totalSize), + }, + { + Name: "renterd_slabbuffers_totalslabs", + Value: float64(len(sb)), + }} +} + +func (or ObjectsResponse) PrometheusMetric() (metrics []prometheus.Metric) { + unavailableObjs := 0 + avgHealth := float64(0.0) + for _, obj := range or.Objects { + if obj.Health == 0 { + unavailableObjs += 1 + } + avgHealth += obj.Health + } + return []prometheus.Metric{ + { + Name: "renterd_objects_avghealth", + Value: float64(avgHealth), + }, + { + Name: "renterd_objects_unavailable", + Value: float64(unavailableObjs), + }} +} + +// SettingsResp represents the settings response fields not available from +// other endpoints. +// No specific prometheus response from: +// /setting/contractset - already available in /params/upload +// /setting/gouging - already available in /params/gouging +// /setting/redundancy - already available in /params/gouging +// /setting/uploadpacking - already available in /params/upload +// +// Prometheus specific: +// /setting/s3authentication +type SettingsResp map[string]interface{} + +func (s SettingsResp) PrometheusMetric() (metrics []prometheus.Metric) { + return +} + +func (sr BusStateResponse) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_state", + Labels: map[string]any{ + "network": sr.Network, + "version": sr.Version, + "commit": sr.Commit, + "start_time": sr.StartTime.String(), + "os": sr.OS, + "build_time": sr.BuildTime.String(), + }, + Value: 1, + }} +} + +func (os ObjectsStatsResponse) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_stats_numobjects", + Value: float64(os.NumObjects), + }, + { + Name: "renterd_stats_numunfinishedobjects", + Value: float64(os.NumUnfinishedObjects), + }, + { + Name: "renterd_stats_minhealth", + Value: os.MinHealth, + }, + { + Name: "renterd_stats_totalobjectsize", + Value: float64(os.TotalObjectsSize), + }, + { + Name: "renterd_stats_totalunfinishedobjectssize", + Value: float64(os.TotalUnfinishedObjectsSize), + }, + { + Name: "renterd_stats_totalsectorssize", + Value: float64(os.TotalSectorsSize), + }, + { + Name: "renterd_stats_totaluploadedsize", + Value: float64(os.TotalUploadedSize), + }} +} + +func (w WalletResponse) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_wallet_scanheight", + Value: float64(w.ScanHeight), + }, + { + Name: "renterd_wallet_spendable", + Value: w.Spendable.Siacoins(), + }, + { + Name: "renterd_wallet_confirmed", + Value: w.Confirmed.Siacoins(), + }, + { + Name: "renterd_wallet_unconfirmed", + Value: w.Unconfirmed.Siacoins(), + }, + { + Name: "renterd_wallet_immature", + Value: w.Immature.Siacoins(), + }} +} + +// WalletOutputsResp represents multiple `SiacoinElement`s. Its prometheus +// encoding is a summary of the count and total value of wallet outputs. +type WalletOutputsResp []SiacoinElement + +func (utxos WalletOutputsResp) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_wallet_numoutputs", + Value: float64(len(utxos)), + }, + { + Name: "renterd_wallet_value", + Value: func() float64 { + var sum types.Currency + for _, utxo := range utxos { + sum = sum.Add(utxo.Value) + } + return sum.Siacoins() + }(), + }, + } +} + +func (txn Transaction) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_wallet_transacaction_pending_inflow", + Labels: map[string]any{ + "txid": txn.ID.String(), + }, + Value: txn.Inflow.Siacoins(), + }, + { + Name: "renterd_wallet_transaction_pending_outflow", + Labels: map[string]any{ + "txid": txn.ID.String(), + }, + Value: txn.Outflow.Siacoins(), + }, + + { + Name: "renterd_wallet_transaction_pending_minerfee", + Labels: map[string]any{ + "txid": txn.ID.String(), + }, + Value: func() float64 { + if len(txn.Raw.MinerFees) > 0 { + return txn.Raw.MinerFees[0].Siacoins() + } + return 0 + }(), + }} +} + +func (m MemoryResponse) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_worker_memory_download_available", + Value: float64(m.Download.Available), + }, + { + Name: "renterd_worker_memory_download_total", + Value: float64(m.Download.Total), + }, + { + Name: "renterd_worker_memory_upload_available", + Value: float64(m.Upload.Available), + }, + { + Name: "renterd_worker_memory_upload_total", + Value: float64(m.Upload.Total), + }} +} + +func (m DownloadStatsResponse) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_worker_stats_avgdownloadspeedmbps", + Value: m.AvgDownloadSpeedMBPS, + }, + { + Name: "renterd_worker_stats_avgoverdrivepct_download", + Value: m.AvgOverdrivePct, + }, + { + Name: "renterd_worker_stats_healthydownloaders", + Value: float64(m.HealthyDownloaders), + }, + { + Name: "renterd_worker_stats_numdownloaders", + Value: float64(m.NumDownloaders), + }} +} + +func (m UploadStatsResponse) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_worker_stats_avgslabuploadspeedmbps", + Value: m.AvgSlabUploadSpeedMBPS, + }, + { + Name: "renterd_worker_stats_avgoverdrivepct_upload", + Value: m.AvgOverdrivePct, + }, + { + Name: "renterd_worker_stats_healthyuploaders", + Value: float64(m.HealthyUploaders), + }, + { + Name: "renterd_worker_stats_numuploaders", + Value: float64(m.NumUploaders), + }} +} + +// AllowListResp represents multiple `typex.PublicKey`s. Its prometheus +// encoding is a list of those keys. +type AllowListResp []types.PublicKey + +func (a AllowListResp) PrometheusMetric() (metrics []prometheus.Metric) { + for _, host := range a { + metrics = append(metrics, prometheus.Metric{ + Name: "renterd_allowed_host", + Labels: map[string]any{ + "host_key": host, + }, + Value: 1, + }) + } + return +} + +// BlockListResp represents multiple blocked host net addresses. Its +// prometheus encoding is a list of those addresses. +type BlockListResp []string + +func (b BlockListResp) PrometheusMetric() (metrics []prometheus.Metric) { + for _, host := range b { + metrics = append(metrics, prometheus.Metric{ + Name: "renterd_blocked_host", + Labels: map[string]any{ + "address": host, + }, + Value: 1, + }) + } + return +} + +// SyncerAddrResp represents the address of the renterd syncer. Its prometheus +// encoding includings a label of the same value. +type SyncerAddrResp string + +func (sar SyncerAddrResp) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_syncer_address", + Labels: map[string]any{ + "address": string(sar), + }, + Value: 1, + }} +} + +// SyncerPeersResp represents the addresses of the syncers peers. Its prometheus +// encoding is a list of those addresses. +type SyncerPeersResp []string + +func (srp SyncerPeersResp) PrometheusMetric() (metrics []prometheus.Metric) { + for _, p := range srp { + metrics = append(metrics, prometheus.Metric{ + Name: "renterd_syncer_peer", + Labels: map[string]any{ + "address": p, + }, + Value: 1, + }) + } + return +} + +// TxPoolFeeResp represents the average transaction fee. Its prometheus +// encoding is a float encoded version of the fee. +// We can't just use a type alias or the custom Currency marshaling methods +// won't be used. +type TxPoolFeeResp struct{ types.Currency } + +func (t TxPoolFeeResp) ToFloat64() (float64, error) { + // Convert string to float64 + return strconv.ParseFloat(t.String(), 64) +} + +func (t TxPoolFeeResp) PrometheusMetric() (metrics []prometheus.Metric) { + floatValue, err := t.ToFloat64() + if err != nil { + fmt.Println("Error:", err) + return + } + return []prometheus.Metric{ + { + Name: "renterd_tpool_fee", + Value: floatValue, + }} +} + +// TxPoolTxResp represents a multiple `types.Transaction`s. Its prometheus +// encoding represents the number of transactions in the slice. +type TxPoolTxResp []types.Transaction + +func (tpr TxPoolTxResp) PrometheusMetric() (metrics []prometheus.Metric) { + return []prometheus.Metric{ + { + Name: "renterd_txpool_numtxns", + Value: float64(len(tpr)), + }} +} diff --git a/api/utils.go b/api/utils.go new file mode 100644 index 000000000..830f236da --- /dev/null +++ b/api/utils.go @@ -0,0 +1,37 @@ +package api + +import ( + "fmt" + "net/http" + + "go.sia.tech/jape" + "go.sia.tech/renterd/internal/prometheus" +) + +func WriteResponse(jc jape.Context, resp prometheus.Marshaller) { + if resp == nil { + return + } + + var responseFormat string + if jc.Check("failed to decode form", jc.DecodeForm("response", &responseFormat)) != nil { + return + } + switch responseFormat { + case "prometheus": + enc := prometheus.NewEncoder(jc.ResponseWriter) + + v, ok := resp.(prometheus.Marshaller) + if !ok { + jc.Error(fmt.Errorf("type %T is not prometheus marshallable", resp), http.StatusInternalServerError) + return + } + + if jc.Check("failed to marshal prometheus response", enc.Append(v)) != nil { + return + } + + default: + jc.Encode(resp) + } +} diff --git a/bus/routes.go b/bus/routes.go index 4f9667d9f..f9acaf389 100644 --- a/bus/routes.go +++ b/bus/routes.go @@ -15,6 +15,7 @@ import ( rhpv2 "go.sia.tech/core/rhp/v2" + "go.sia.tech/renterd/internal/prometheus" rhp3 "go.sia.tech/renterd/internal/rhp/v3" "go.sia.tech/renterd/stores/sql" @@ -138,7 +139,7 @@ func (b *Bus) consensusAcceptBlock(jc jape.Context) { } func (b *Bus) syncerAddrHandler(jc jape.Context) { - jc.Encode(b.s.Addr()) + api.WriteResponse(jc, api.SyncerAddrResp(b.s.Addr())) } func (b *Bus) syncerPeersHandler(jc jape.Context) { @@ -146,7 +147,7 @@ func (b *Bus) syncerPeersHandler(jc jape.Context) { for _, p := range b.s.Peers() { peers = append(peers, p.String()) } - jc.Encode(peers) + api.WriteResponse(jc, api.SyncerPeersResp(peers)) } func (b *Bus) syncerConnectHandler(jc jape.Context) { @@ -162,7 +163,7 @@ func (b *Bus) consensusStateHandler(jc jape.Context) { if jc.Check("couldn't fetch consensus state", err) != nil { return } - jc.Encode(cs) + api.WriteResponse(jc, cs) } func (b *Bus) consensusNetworkHandler(jc jape.Context) { @@ -172,11 +173,11 @@ func (b *Bus) consensusNetworkHandler(jc jape.Context) { } func (b *Bus) txpoolFeeHandler(jc jape.Context) { - jc.Encode(b.cm.RecommendedFee()) + api.WriteResponse(jc, api.TxPoolFeeResp{Currency: b.cm.RecommendedFee()}) } func (b *Bus) txpoolTransactionsHandler(jc jape.Context) { - jc.Encode(b.cm.PoolTransactions()) + api.WriteResponse(jc, api.TxPoolTxResp(b.cm.PoolTransactions())) } func (b *Bus) txpoolBroadcastHandler(jc jape.Context) { @@ -198,7 +199,7 @@ func (b *Bus) bucketsHandlerGET(jc jape.Context) { if jc.Check("couldn't list buckets", err) != nil { return } - jc.Encode(resp) + api.WriteResponse(jc, prometheus.Slice(resp)) } func (b *Bus) bucketsHandlerPOST(jc jape.Context) { @@ -262,7 +263,7 @@ func (b *Bus) walletHandler(jc jape.Context) { return } - jc.Encode(api.WalletResponse{ + api.WriteResponse(jc, api.WalletResponse{ Balance: balance, Address: address, ScanHeight: b.w.Tip().Height, @@ -522,7 +523,7 @@ func (b *Bus) hostsHandlerPOST(jc jape.Context) { if jc.Check(fmt.Sprintf("couldn't fetch hosts %d-%d", req.Offset, req.Offset+req.Limit), err) != nil { return } - jc.Encode(hosts) + api.WriteResponse(jc, prometheus.Slice(hosts)) } func (b *Bus) hostsRemoveHandlerPOST(jc jape.Context) { @@ -603,7 +604,7 @@ func (b *Bus) contractsSpendingHandlerPOST(jc jape.Context) { func (b *Bus) hostsAllowlistHandlerGET(jc jape.Context) { allowlist, err := b.hs.HostAllowlist(jc.Request.Context()) if jc.Check("couldn't load allowlist", err) == nil { - jc.Encode(allowlist) + api.WriteResponse(jc, api.AllowListResp(allowlist)) } } @@ -623,7 +624,7 @@ func (b *Bus) hostsAllowlistHandlerPUT(jc jape.Context) { func (b *Bus) hostsBlocklistHandlerGET(jc jape.Context) { blocklist, err := b.hs.HostBlocklist(jc.Request.Context()) if jc.Check("couldn't load blocklist", err) == nil { - jc.Encode(blocklist) + api.WriteResponse(jc, api.BlockListResp(blocklist)) } } @@ -664,7 +665,7 @@ func (b *Bus) contractsHandlerGET(jc jape.Context) { FilterMode: filterMode, }) if jc.Check("couldn't load contracts", err) == nil { - jc.Encode(contracts) + api.WriteResponse(jc, prometheus.Slice(contracts)) } } @@ -922,7 +923,7 @@ func (b *Bus) contractsPrunableDataHandlerGET(jc jape.Context) { return contracts[i].Prunable > contracts[j].Prunable }) - jc.Encode(api.ContractsPrunableDataResponse{ + api.WriteResponse(jc, api.ContractsPrunableDataResponse{ Contracts: contracts, TotalPrunable: totalPrunable, TotalSize: totalSize, @@ -1175,7 +1176,7 @@ func (b *Bus) objectsHandlerGET(jc jape.Context) { } else if jc.Check("failed to query objects", err) != nil { return } - jc.Encode(resp) + api.WriteResponse(jc, resp) } func (b *Bus) objectHandlerPUT(jc jape.Context) { @@ -1273,7 +1274,7 @@ func (b *Bus) slabbuffersHandlerGET(jc jape.Context) { if jc.Check("couldn't get slab buffers info", err) != nil { return } - jc.Encode(buffers) + api.WriteResponse(jc, api.SlabBuffersResp(buffers)) } func (b *Bus) objectsStatshandlerGET(jc jape.Context) { @@ -1632,7 +1633,7 @@ func (b *Bus) paramsHandlerUploadGET(jc jape.Context) { uploadPacking = us.Packing.Enabled } - jc.Encode(api.UploadParams{ + api.WriteResponse(jc, api.UploadParams{ ContractSet: contractSet, CurrentHeight: b.cm.TipState().Index.Height, GougingParams: gp, @@ -1664,7 +1665,7 @@ func (b *Bus) paramsHandlerGougingGET(jc jape.Context) { if jc.Check("could not get gouging parameters", err) != nil { return } - jc.Encode(gp) + api.WriteResponse(jc, gp) } func (b *Bus) gougingParams(ctx context.Context) (api.GougingParams, error) { @@ -1724,7 +1725,7 @@ func (b *Bus) handleGETAlerts(jc jape.Context) { if jc.Check("failed to fetch alerts", err) != nil { return } - jc.Encode(ar) + api.WriteResponse(jc, ar) } func (b *Bus) handlePOSTAlertsDismiss(jc jape.Context) { @@ -1859,7 +1860,7 @@ func (b *Bus) contractTaxHandlerGET(jc jape.Context) { } func (b *Bus) stateHandlerGET(jc jape.Context) { - jc.Encode(api.BusStateResponse{ + api.WriteResponse(jc, api.BusStateResponse{ StartTime: api.TimeRFC3339(b.startTime), BuildState: api.BuildState{ Version: build.Version(), diff --git a/internal/prometheus/encoder.go b/internal/prometheus/encoder.go new file mode 100644 index 000000000..695a3aaa6 --- /dev/null +++ b/internal/prometheus/encoder.go @@ -0,0 +1,76 @@ +package prometheus + +import ( + "encoding/json" + "fmt" + "io" + "strings" +) + +// A Marshaller can be marshalled into Prometheus samples +type Marshaller interface { + PrometheusMetric() []Metric +} + +type marshallerSlice[M Marshaller] struct { + slice []M +} + +func (s marshallerSlice[M]) PrometheusMetric() []Metric { + var metrics []Metric + for _, m := range s.slice { + metrics = append(metrics, m.PrometheusMetric()...) + } + return metrics +} + +func (s marshallerSlice[M]) MarshalJSON() ([]byte, error) { + return json.Marshal(s.slice) +} + +// Slice converts a slice of Prometheus marshallable objects into a +// slice of prometheus.Marshallers. +func Slice[T Marshaller](s []T) Marshaller { + return marshallerSlice[T]{slice: s} +} + +// An Encoder writes Prometheus samples to the writer +type Encoder struct { + used bool + sb strings.Builder + w io.Writer +} + +// Append marshals a Marshaller and appends it to the encoder's buffer. +func (e *Encoder) Append(m Marshaller) error { + e.sb.Reset() // reset the string builder + + // if this is not the first, add a newline to separate the samples + if e.used { + e.sb.Write([]byte("\n")) + } + e.used = true + + for i, m := range m.PrometheusMetric() { + if i > 0 { + // each sample must be separated by a newline + e.sb.Write([]byte("\n")) + } + + if err := m.encode(&e.sb); err != nil { + return fmt.Errorf("failed to encode metric: %v", err) + } + } + + if _, err := e.w.Write([]byte(e.sb.String())); err != nil { + return fmt.Errorf("failed to write metric: %v", err) + } + return nil +} + +// NewEncoder creates a new Prometheus encoder. +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{ + w: w, + } +} diff --git a/internal/prometheus/encoder_test.go b/internal/prometheus/encoder_test.go new file mode 100644 index 000000000..1708cd0b9 --- /dev/null +++ b/internal/prometheus/encoder_test.go @@ -0,0 +1,62 @@ +package prometheus + +import ( + "bytes" + "testing" +) + +type encType struct { + Test float64 +} + +func (e encType) PrometheusMetric() []Metric { + return []Metric{{ + Name: "test", + Labels: map[string]any{ + "label": 10, + }, + Value: e.Test, + }} +} + +func TestEncode(t *testing.T) { + v := encType{ + Test: 1.5, + } + + var b bytes.Buffer + e := NewEncoder(&b) + if err := e.Append(&v); err != nil { + t.Fatal(err) + } + + got := string(b.Bytes()) + const expected = `test{label="10"} 1.5` + if got != expected { + t.Fatalf("prometheus marshaling: expected %s, got %s", expected, got) + } +} + +func TestEncodeSlice(t *testing.T) { + v := []encType{ + { + Test: 1.5, + }, + { + Test: 1.4, + }, + } + + var b bytes.Buffer + e := NewEncoder(&b) + if err := e.Append(Slice(v)); err != nil { + t.Fatal(err) + } + + got := string(b.Bytes()) + const expected = `test{label="10"} 1.5 +test{label="10"} 1.4` + if got != expected { + t.Fatalf("prometheus marshaling: expected %s, got %s", expected, got) + } +} diff --git a/internal/prometheus/metric.go b/internal/prometheus/metric.go new file mode 100644 index 000000000..86839ef50 --- /dev/null +++ b/internal/prometheus/metric.go @@ -0,0 +1,67 @@ +package prometheus + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// A Metric is a Prometheus metric. +type Metric struct { + Name string + Labels map[string]any + Value float64 + Timestamp time.Time +} + +// encode encodes a Metric into a Prometheus metric string. +func (m *Metric) encode(sb *strings.Builder) error { + sb.WriteString(m.Name) + + // write optional labels + if len(m.Labels) > 0 { + sb.WriteString("{") + n := len(m.Labels) + for k, v := range m.Labels { + sb.WriteString(k) + sb.WriteString(`="`) + switch v := v.(type) { + case string: + sb.WriteString(v) + case []byte: + sb.Write(v) + case int: + sb.WriteString(strconv.Itoa(v)) + case int64: + sb.WriteString(strconv.FormatInt(v, 10)) + case float64: + sb.WriteString(strconv.FormatFloat(v, 'f', -1, 64)) + case bool: + sb.WriteString(strconv.FormatBool(v)) + case fmt.Stringer: + sb.WriteString(v.String()) + default: + return fmt.Errorf("unsupported label value %T", v) + } + sb.WriteString(`"`) + + if n > 1 { + sb.WriteString(",") + } + n-- + } + sb.WriteString("}") + } + + // write value + sb.WriteString(" ") + sb.WriteString(strconv.FormatFloat(m.Value, 'f', -1, 64)) + + // write optional timestamp + if !m.Timestamp.IsZero() { + sb.WriteString(" ") + sb.WriteString(strconv.FormatInt(m.Timestamp.UnixMilli(), 10)) + } + return nil +} diff --git a/worker/worker.go b/worker/worker.go index 9ff193b6d..074680cf2 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -445,7 +445,7 @@ func (w *Worker) downloadsStatsHandlerGET(jc jape.Context) { }) // encode response - jc.Encode(api.DownloadStatsResponse{ + api.WriteResponse(jc, api.DownloadStatsResponse{ AvgDownloadSpeedMBPS: math.Ceil(stats.avgDownloadSpeedMBPS*100) / 100, AvgOverdrivePct: math.Floor(stats.avgOverdrivePct*100*100) / 100, HealthyDownloaders: healthy, @@ -470,7 +470,7 @@ func (w *Worker) uploadsStatsHandlerGET(jc jape.Context) { }) // encode response - jc.Encode(api.UploadStatsResponse{ + api.WriteResponse(jc, api.UploadStatsResponse{ AvgSlabUploadSpeedMBPS: math.Ceil(stats.avgSlabUploadSpeedMBPS*100) / 100, AvgOverdrivePct: math.Floor(stats.avgOverdrivePct*100*100) / 100, HealthyUploaders: stats.healthyUploaders, @@ -829,7 +829,7 @@ func (w *Worker) idHandlerGET(jc jape.Context) { } func (w *Worker) memoryGET(jc jape.Context) { - jc.Encode(api.MemoryResponse{ + api.WriteResponse(jc, api.MemoryResponse{ Download: w.downloadManager.mm.Status(), Upload: w.uploadManager.mm.Status(), })