Prometheus Metrics

S4 exposes metrics in Prometheus text format on the main HTTP port at /metrics.

Enable / Disable

Metrics are enabled by default. To disable:

export S4_METRICS_ENABLED=false

When disabled, the /metrics endpoint returns 503 Service Unavailable.

Endpoint

curl http://localhost:9000/metrics

Response: Prometheus text format (text/plain; version=0.0.4)

Available Metrics

http_requests_total

Type: Counter

Total number of HTTP requests, labeled by method, status code, and normalized path. A request is counted once it has been answered, so a scrape of /metrics shows up in the next scrape rather than in its own.

http_requests_total{method="GET",status="200",path="/{bucket}/{key}"} 1523
http_requests_total{method="PUT",status="200",path="/{bucket}/{key}"} 847
http_requests_total{method="DELETE",status="204",path="/{bucket}/{key}"} 32

http_request_duration_seconds

Type: Histogram

Request duration in seconds, labeled by method and normalized path.

http_request_duration_seconds_bucket{method="GET",path="/{bucket}/{key}",le="0.001"} 1200
http_request_duration_seconds_bucket{method="GET",path="/{bucket}/{key}",le="0.0025"} 1391
...
http_request_duration_seconds_bucket{method="GET",path="/{bucket}/{key}",le="30"} 1523
http_request_duration_seconds_bucket{method="GET",path="/{bucket}/{key}",le="+Inf"} 1523
http_request_duration_seconds_count{method="GET",path="/{bucket}/{key}"} 1523
http_request_duration_seconds_sum{method="GET",path="/{bucket}/{key}"} 3.142

Every histogram S4 exports has the same bucket bounds, in seconds: 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30. Buckets of different nodes add up, so a cluster-wide percentile is one query (see below).

s4_build_info

Type: Gauge, always 1

The version of the node and the edition it runs as. An Enterprise build without a licence runs, and reports, as ce.

s4_build_info{version="1.1.1",edition="ee"} 1

s4_license_expiry_timestamp_seconds

Type: Gauge

Only on a node running under an Enterprise licence: when the licence expires, in Unix seconds.

# Days left on the licence.
(s4_license_expiry_timestamp_seconds - time()) / 86400

Cluster

A cluster node exports the metrics below from its start, at zero: every value of a closed label, every other member of S4_POOL_NODES as a peer, and the pool. An empty graph means nothing happened, not that the data is missing.

Every number is the view of the node that exports it. A quorum operation is counted by the node that coordinated it, a call to a peer by the node that made it, and a member's status is the one this node holds. So scrape every node. No metric carries a node label, because Prometheus' instance already tells the nodes apart. peer is a member's name in S4_POOL_NODES. Buckets, keys and request ids never become labels.

Metric Type Labels What it counts, and what a change means
s4_cluster_nodes gauge pool, status = alive, suspect, dead, left The members of S4_POOL_NODES by status as this node sees them, itself included. A member it has not heard from since it started is dead, so the four add up to the pool size. suspect and left stay at 0: a suspected member is reported straight as dead, and a stopping node does not announce that it leaves
s4_cluster_peer_up gauge peer 1 while this node holds the peer alive. A peer at 0 on every node is down; at 0 on one node only, the network between the two is
s4_cluster_epoch gauge — The topology epoch of this node. The nodes of a pool agree on it
s4_cluster_migration_pending_records gauge — This node's single-mode objects and bucket markers still waiting to reach every owner after a switch to cluster mode; zero on an empty peer. Wait for zero on every node before switching clients. A stalled value means an owner is unavailable, runs another S4 version, or cannot accept the copy; the source log names why
s4_cluster_migration_records_total counter outcome = migrated, superseded Single-mode records whose migration finished during this process. migrated: every owner accepted the earlier copy. superseded: a newer cluster write or delete took precedence. Completed records are not counted again after restart
s4_quorum_operations_total counter operation = write, read, head, delete, list, multipart; outcome = ok, not_found, quorum_not_met, error; origin = client, system Quorum operations this node coordinated, one per operation. origin="client" counts the operations that served a request to this node, S3 or admin; origin="system" counts the node's own work, such as publishing the pool's records when it starts, moving a grown pool's keys and background workers. A request into a bucket costs one more: the head of the bucket's marker that tells whether the bucket exists (a GET asks only when the object is not found). quorum_not_met means fewer replicas answered than the operation needs and the client got an error: the one to alert on
s4_quorum_duration_seconds histogram operation, origin How long those operations took. The latency clients see is origin="client"
s4_peer_requests_total counter peer, outcome = ok, error, timeout, skipped, cancelled Calls to the peer, one per call after its retries: quorum traffic, hints, anti-entropy and erasure coding alike. skipped: gossip holds the peer dead and no call was made. cancelled: the caller stopped waiting, usually because the quorum was already reached. Failures that pile up on one peer point at the node that holds the cluster back
s4_peer_request_duration_seconds histogram peer How long the calls that got an answer or an error took (ok, error, timeout)
s4_read_repairs_total counter outcome = ok, failed Writes of the latest version to a stale replica after a quorum read. A GET repairs nothing, so this moves with CopyObject, not with read traffic (see Read Repair)
s4_hints_pending gauge peer Hints this node holds on disk for the peer, counted from disk at start. It rises while the peer is away and falls to 0 once the peer is back and the hints are delivered
s4_hints_stored_total counter — Hints written: a write or delete that did not reach a replica
s4_hints_delivered_total counter — Hints after which their replica was at the current state of the key
s4_hints_dropped_total counter reason = full, expired, rejected, superseded, ambiguous Hints that will not be delivered. full: the 1 GiB limit per peer was reached. expired: the hint outlived S4_HINT_TTL_HOURS. rejected: the replica refused what delivery sent it or is too old to delete one exact version, a copy came with no HLC at all, or the replica's record without an HLC (data written before the node joined a cluster, ordered by its modification time) is newer than the delete it missed. superseded: the key left replication, for erasure coding or another placement. ambiguous: the newest copy is on fewer replicas than a confirmed write leaves while another replica has no key, so it may be a copy that missed a delete, and it is not spread. A key behind any of these but superseded stays behind on that replica until a quorum read repairs it
s4_anti_entropy_rounds_total counter outcome = complete, incomplete, skipped Anti-entropy rounds this node started. complete: it compared every key it shares with the peer and settled every one that differed. incomplete: the peer stopped answering, a key could not be settled, or more keys differed than one round settles (S4_ANTI_ENTROPY_MAX_KEYS_PER_ROUND); the rest waits for the next rounds. skipped: a live node of the pool announces protocol below 8, the live peer whose turn it is has not announced its protocol yet, or no peer is alive. An unknown peer retains its turn. Current protocol: 9. A node with S4_ANTI_ENTROPY_ENABLED=false counts none
s4_anti_entropy_round_duration_seconds histogram — How long the rounds that ran took
s4_anti_entropy_divergent_keys_total counter — Keys a round found in different versions on this node and its peer, or on one of them only
s4_anti_entropy_repairs_total counter outcome = ok, failed, ambiguous What became of each of those keys; the three add up to the divergent keys. ok: every replica that answered holds the newest version now. failed: a replica did not answer or refused it. ambiguous: too few owners confirm the newest copy and an absent owner cannot exclude an older delete, including one whose tombstone was purged; the copy is not spread
s4_repair_frontier_lag_seconds gauge — Age of the oldest repair frontier boundary over the peers of the pool, as of the last round: how long ago the oldest last complete round with a peer started, plus 60.5 s (a boundary stands that far before its round, the time a delete may still take to reach a replica), counted from the node's start for a peer without one; 0 before the first round. Only complete rounds advance cuts; cuts are held in memory and start over after a restart
s4_tombstones_active gauge — Tombstones left after the last tombstone GC pass; 0 before the first
s4_tombstones_purged_total counter — Tombstones GC removed
s4_bucket_unenforced_configs gauge kind = versioning, object_lock Buckets whose versioning or Object Lock this node stores from an earlier release, which accepted them in cluster mode; cluster mode does not enforce either. Counted once the node has handed its earlier configurations over at start (within a minute), again after each such pass and every 10 minutes; a bucket leaves the count when it is deleted. Above 0: see Object Lock → Cluster Mode
s4_bucket_config_adoption_keys_total counter outcome = adopted, superseded, orphaned, deferred Bucket configurations an earlier release kept on this node alone, by what handing them to the nodes that keep them now came to. adopted: the nodes that keep the configuration hold it. superseded: they held a version changed later, or deleted it later. orphaned: the bucket was deleted or created again since, and the configuration does not count. deferred: a node that keeps it did not answer, and the next pass, 30 s later, counts it again. Moves only after an upgrade from such a release (see Federation)
s4_bucket_config_quorum_reads gauge — 1 when this node reads bucket configurations through the quorums and disables local fallback; 0 during its initial legacy adoption. Quorum reads persist after adoption or uncertain configuration history, including while another node is down
s4_bucket_metadata_stage gauge pool Where this node is in the move of the pool's bucket records and settings to more replicas: 0 three replicas, 1 writing to both layouts while bringing the older records over, 2 reading from the new layout, 3 settled. A pool with nothing to move is 3 from the start. A value below 3 for longer than a few minutes means a node of the pool is down or runs an earlier release; /api/admin/cluster/health names it (see Federation → Buckets)
s4_bucket_metadata_replication_factor gauge pool Replicas the pool keeps each bucket's records and settings on once the move settled: the data's in a replicated pool, 5 for RS 4+2 on six nodes, or S4_BUCKET_METADATA_REPLICATION_FACTOR
s4_bucket_metadata_backfill_keys_total counter outcome = copied, already, unconfirmed, failed, dropped Bucket records and settings this node brought to the new replicas during the move. copied: a new replica got them. already: every replica held them. unconfirmed: a write that was refused left them on too few nodes, and they stay as they were — they read as absent before the move and after. failed: a replica did not answer, and the next pass, 10 s later, counts them again. dropped: a record an earlier release made up on a node that did not keep it, removed before that node keeps it
s4_bucket_metadata_backfill_passes_total counter outcome = complete, incomplete Passes of that backfill. The move goes on once a pass is complete on every node
s4_pool_generation gauge pool Epoch of the pool generation this node places keys by. The first generation is 1, and each growth of the pool adds one. While the pool grows it is the epoch the keys move from; once the growth settled, the grown one. Nodes that disagree for longer than a growth takes point at a node that has not settled (see Clustering → Growing a pool)
s4_rebalance_ranges gauge state = pending, streaming, verified, cut_over Ranges of the ring of a growing pool by state on this node. pending: not begun; streaming: being moved; verified: the new owners hold what the old owners confirmed; cut_over: read through the grown generation. All 0 while the pool does not grow
s4_rebalance_keys_total counter — Keys this node brought to the new owners of a growing pool: a new owner received a version it lacked
s4_rebalance_bytes_total counter — Bytes of the copies this node sent to the new owners
s4_rebalance_remaining_bytes gauge — Estimate of what this node still has to go through before it handed its keys over: the bytes it stores, in the share of its ranges not handed over yet. It counts erasure-coded shards too, which do not move, so in an erasure-coded pool it is an upper bound. 0 once the node handed over everything, on a new node, and while the pool does not grow
s4_rebalance_paused gauge reason = operator, no_room 1 while the move of this node's keys is paused. operator: paused through the admin API, until it is resumed. no_room: a new owner has less free space than S4_REBALANCE_MIN_FREE_RATIO, and the keys meant for it wait until space is freed
s4_rebalance_errors_total counter reason = checksum, unavailable, ambiguous, storage, placement Keys that failed a try to reach their new owners. checksum: a copy did not match its CRC32 or SHA-256, and it is taken from another replica. unavailable: a replica did not answer. ambiguous: no write quorum of the old owners holds the newest version. storage and placement: this node could not read the key or place it. Every such key is tried again; the move of its range does not end until it got through
s4_pool_generation_fenced_writes_total counter — Writes this node refused because their coordinator placed them by an earlier pool generation than the one the pool moves to: a node of an earlier release, or one restarted with the pool's earlier S4_POOL_NODES. The client of that node gets 503. Restart it with the current release and configuration. Copies that repair brings, ordered by their HLC, are still accepted
s4_bucket_metadata_fenced_writes_total counter — Writes of bucket records and settings this node refused because they came from a node of an earlier release, or one behind the pool, after the move began. Any increase means such a node is in the pool; it cannot create or delete buckets until it runs this release
s4_iam_fenced_writes_total counter — Writes of users and root's state this node refused because they were made outside the current lease of IAM changes: by a node whose lease ran out or was taken over by a later one while it stalled or was cut off. Such a write lands nowhere, and the change it belonged to was answered 503. A short burst after a node stalled is fencing at work; a steady rate means a node keeps writing with a lease it no longer holds
s4_iam_epoch_applied gauge — Wall time, in Unix milliseconds, of the newest change of users or root's state this node holds, deletes included; 0 while it holds none. Nodes of one pool show the same value a moment after a change; one that stays behind missed changes
s4_iam_replica_lag_seconds gauge — How long ago a majority of the pool last agreed with this node's copy of users and keys — since the node started, while none has yet. It stays below a second in a healthy pool. Past S4_IAM_MAX_STALENESS_SECS the node refuses IAM credentials with 503
s4_iam_auth_refused_total counter reason = starting, stale Requests with the password, token or S3 key of an IAM user this node answered 503, because no majority of the pool had agreed with its copy of users and keys. starting: not yet since the node started — it is catching up. stale: not for longer than S4_IAM_MAX_STALENESS_SECS — the node is cut off from most of its pool. Root and the S3 credentials of the environment are never refused this way
s4_iam_config_mismatch gauge kind = root, jwt_secret, encryption_key 1 while the live nodes of the pool are configured differently: root's name or password (root), S4_JWT_SECRET (jwt_secret), or the key the secrets of S3 credentials are encrypted with (encryption_key). The cluster health is then critical and names the node that differs. Nodes compare fingerprints carried by gossip, never the values
s4_iam_records_by_key_id gauge key_id How many IAM users this node holds whose S3 secret is encrypted with the key of this id; key_id="clear" counts secrets still in clear text, as an earlier release kept them. The series of the current key is always there, 0 while no user has S3 credentials. After a new S4_IAM_ENCRYPTION_KEY, the series of the old key falls to 0 once every secret is encrypted again: then the old key can be removed. Present on single nodes too
s4_iam_adoption_pending gauge — 1 while some node of the pool keeps IAM users of its own that the pool has not taken over — an earlier release, or the node as a single node, stored them on it alone — and IAM changes answer 409 IamAdoptionPending; 0 once every node announced it keeps none. A new pool shows 1 for a moment after it starts. After an upgrade it stays 1 until the operator decides through /api/admin/iam/adoption (see Cluster Mode)
s4_node_disk_total_bytes gauge — Size of the filesystem under the data directory, measured every 30 seconds for the peers; 0 when it cannot be measured
s4_node_disk_used_bytes gauge — Space in use on that filesystem
s4_scrubber_blobs_scanned_total counter — Blobs the background scrubber verified against their CRC32
s4_scrubber_corruptions_found_total counter — Corrupt blobs it found
s4_scrubber_corruptions_healed_total counter — Corrupt blobs it rewrote from a healthy replica
s4_scrubber_scan_progress gauge — How far the current full scan has got, from 0 to 1

Every histogram has the bucket bounds above. For each node the hint counters add up: pending = pending at start + stored − delivered − dropped.

The repair frontier lag of a node grows while one of its peers is down or its rounds with a peer do not complete: tombstones of the keys it shares with that peer are not removed meanwhile, and s4_tombstones_active grows with the deletes. Once every peer has had a complete round, the lag falls back below 60.5 s plus a turn over the peers, S4_ANTI_ENTROPY_INTERVAL_SECS × peers. A complete round records a cut at its start minus the 60.5 s arrival window; incomplete and skipped rounds record none. The node itself needs no cut. After a restart tombstone GC waits for fresh complete rounds with every other owner of each key. A node with rounds disabled still answers its peers, but advances no cuts of its own and purges no tombstones.

What is worth an alert:

# A member some node holds not alive.
sum by (instance) (s4_cluster_nodes{status!="alive"}) > 0

# Quorum operations failing, by operation.
sum by (operation) (rate(s4_quorum_operations_total{outcome="quorum_not_met"}[5m])) > 0

# Calls failing, by peer: the node the cluster is waiting for.
sum by (peer) (rate(s4_peer_requests_total{outcome=~"error|timeout"}[5m])) > 0

# Writes a replica will not get from hinted handoff.
sum by (instance, reason) (increase(s4_hints_dropped_total[1h])) > 0

# Hints still waiting although their peer is up again. Delivery runs every 30
# seconds, so give it `for: 5m`. A hint also waits while another replica of its
# key is down, so this fires during a second outage too.
s4_hints_pending > 0 and on (instance, peer) s4_cluster_peer_up == 1

# A filesystem under a data directory over 90%.
s4_node_disk_used_bytes / (s4_node_disk_total_bytes > 0) > 0.9

# A growing pool whose move waits for space on a new node.
max by (instance) (s4_rebalance_paused{reason="no_room"}) > 0

# A node that keeps writing by an earlier pool generation.
sum by (instance) (rate(s4_pool_generation_fenced_writes_total[5m])) > 0

And the percentiles of the cluster:

# p99 of the quorum writes of client requests.
histogram_quantile(0.99, sum by (le) (rate(s4_quorum_duration_seconds_bucket{operation="write", origin="client"}[5m])))

# p99 of each peer.
histogram_quantile(0.99, sum by (le, peer) (rate(s4_peer_request_duration_seconds_bucket[5m])))

The cluster routes of the admin API answer from the same state these metrics are written from — see Clustering → Health Check.

Bit rot and the scrubber (cluster mode)

The background scrubber verifies every blob on this node against its CRC32 and, when one fails, fetches a healthy copy from a replica and writes it back:

# Blobs verified, damage found, and damage actually repaired — per node.
s4_scrubber_blobs_scanned_total
s4_scrubber_corruptions_found_total
s4_scrubber_corruptions_healed_total

# How far the current full scan has got, 0 .. 1.
s4_scrubber_scan_progress

found minus healed is what this node is still carrying damaged — on a single node, where there is no replica to fetch from, that is all of it. A counter that stays at zero while blobs_scanned grows is the healthy case; a blobs_scanned that never grows at all means the scrubber is finding no volumes to walk, which the node also says in its log at startup. What happens around these numbers is described in Deduplication → Bit rot.

Erasure coding (Enterprise)

An Enterprise build with an erasure-coded pool exports an s4_ec_* family alongside the metrics above: transcoding, repair, tiering, packed segments, set layout and node rejoin. Every one of them is defined, with its labels and what a moving value means, in Erasure Coding → Metrics. That page is the reference; this one does not repeat it.

Two of those series exist for one job — watching a replaced node come back — and they behave differently from the rest, which is worth knowing before an empty graph is read as good news:

# Shards the pool still misses from a replaced node. Zero ends the rebuild.
s4_ec_node_shards_missing{node_id="0d3bb4d8-876d-4b6a-a27f-49a69d11d3ae"}

# Whether that zero can be trusted: complete, incomplete or unknown.
s4_ec_node_rebuild_verdict{node_id="0d3bb4d8-876d-4b6a-a27f-49a69d11d3ae"} == 1

# Bytes the rebuild has written, and which limit is pacing it.
rate(s4_ec_repair_bytes_total{leg="write"}[5m])
rate(s4_ec_repair_throttled_total[5m])

The first two are written when the node's rebuild status is asked for through the admin API, because the count behind them is a walk over the pool's manifests rather than something a scrape can do. A node nobody has asked about has no series at all — absent, not zero. The full procedure, including the request that refreshes them, is in Erasure Coding → Replacing a node.

Read traffic is the other pair worth having on a dashboard, because it is what says whether a pool is paying for its redundancy on every read:

# Bytes reads pulled out of shards, and how much of it crossed a zone.
rate(s4_ec_read_shard_bytes_total[5m])
rate(s4_ec_read_shard_bytes_total{scope="cross_zone"}[5m])

# Round trips those reads made. Shards are fetched one after another, so this
# is the part of read latency that narrowing the read takes away.
rate(s4_ec_read_shard_fetches_total[5m])

Unlike the two above these are written as reads happen, and all eight series exist from startup at zero. A node reporting everything under scope="unknown_zone" has no S4_EC_NODE_TOPOLOGY and cannot place itself in the pool; the totals are still right. What the switch behind these numbers does is in Erasure Coding → Reading only what is needed.

Small objects in an EC pool are not erasure coded; they are placed as RF=3 with an explicit record saying so. Two counters say whether that record is where it belongs:

# Where reads found the record. Steady pool_fanout means it is not on the nodes
# that hold the objects, and every such read costs the whole pool instead of RF.
rate(s4_ec_small_placement_resolves_total{source="replicas"}[5m])
rate(s4_ec_small_placement_resolves_total{source="pool_fanout"}[5m])

# Records a write could not get onto a majority of an object's replicas, and
# the sweep settling them afterwards. `recorded` rising while `cleared` stays
# flat is a pool that is not finishing the job.
rate(s4_ec_small_placement_hints_total{outcome="recorded"}[5m])
rate(s4_ec_small_placement_hints_total{outcome="cleared"}[5m])

One thing those reads no longer do is notice a parity shard going bad, because they no longer fetch one. The local scrub pass does that instead, and it has no series of its own: what it finds is watched through the repairs it queues — s4_ec_repair_jobs_total, s4_ec_repair_queue_depth and the repair status of the pool. A pool where nothing is ever repaired is a pool whose scrub worker may simply not be running.

The repair queue gauges are refreshed the same way — by a request for pool health — and they are the pair to watch during an incident:

# How deep the repair queue of one set is.
s4_ec_repair_queue_depth{erasure_set="1"}

# And what it is made of: chunks by the number of shards they are missing.
s4_ec_repair_queue_depth_by_losses{erasure_set="1"}

The second is the one that says whether a backlog is serious. Its buckets add up to the first, and a bucket above 1 means chunks whose redundancy is partly gone — read against the profile of that set, because the same count means different things to ec-rs-small and ec-rs-dense. Which of those tasks is carried out first is decided by the same number, and is described in Erasure Coding → Which repair runs next.

Path Normalization

To prevent high cardinality, request paths are normalized:

Actual Path Normalized Path
/my-bucket/my-key.txt /{bucket}/{key}
/my-bucket /{bucket}
/api/admin/users /api/admin
/api/stats /api/stats
/metrics /metrics
/health /health

Grafana Integration

A dashboard for a cluster ships with the documentation: grafana/s4-cluster.json. Import it in Grafana (Dashboards → New → Import) and pick the Prometheus data source that scrapes the nodes. It shows membership as each node sees it, quorum operations and their failures, calls to each peer, hinted handoff, read repair, anti-entropy, tombstones, disks, the scrubber, versions and the licence, with a node picker over every instance that exports s4_cluster_epoch. A test holds it to the metric list above: every query names a metric, label and label value a node exports, and every cluster metric is on a panel.

Example queries for any node, single or in a cluster:

# Request rate (requests per second)
rate(http_requests_total[5m])

# Average latency
rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])

# Error rate (4xx + 5xx)
sum(rate(http_requests_total{status=~"[45].."}[5m]))

# P99 latency of the whole cluster (drop `sum by (le)` for each node on its own)
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

Prometheus Configuration

Add S4 to your prometheus.yml:

scrape_configs:
  - job_name: 's4'
    scrape_interval: 15s
    static_configs:
      - targets: ['localhost:9000']
    metrics_path: '/metrics'