Cap timer histogram buckets at 13 instead of 69 - #891
Merged
Conversation
publishPercentileHistogram() makes Micrometer emit a 69-step exponential ladder per label set. Measured on a public-multiregion pod, the two biggest timer families were 91% of a 74.5 MB /metrics body, over the 64 MB scrape limit, so the agent rejected the whole response and every dshackle metric from that pod was lost. Add a MeterFilter that replaces the percentile histogram with 12 explicit latency boundaries (1ms..30s), giving 13 buckets including +Inf. The filter only touches timers that already requested a percentile histogram, so no timer gains buckets, and non-timer distributions are left alone because their recorded values are not nanoseconds. Keeps every histogram_quantile consumer working, at coarser resolution.
msizov
approved these changes
Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
publishPercentileHistogram()makes Micrometer expand each timer into a 69-step exponentialleladder (0.001s → 30s). Measured on a livepublic-multiregionpod (image6a2fb45, container age 10d22h), that ladder is almost the entire/metricsresponse:dshackle_upstream_rpc_conn_seconds_bucketdshackle_request_grpc_native_response_seconds_bucketdshackle_request_grpc_response_seconds_bucketBody is 74,507,051 bytes. Every
*_seconds_bucketline together is 94.7% of it. Label cardinality is not the issue —upstream.rpc.connhas only 4,360 label sets. 4,360 × 69 = 300,840 series exactly. The multiplier is the ladder: 10.10 KB of response body per label set.Our metrics agent has a hard 64 MB cap on the raw response body, applied before parsing, with no partial ingestion. Over the cap it drops the entire scrape. So two oversized families take out every
dshackle_*metric from the pod —upstreams_availability,current_head,stuck_head,upstream_blocks, the JVM metrics, all of it. Two of our threepublic-multiregionclusters have been dark for 5-6 days each. dshackle itself is healthy throughout; it served 9.27M requests in the 30 minutes before this was measured.Restarting is not a fix. A fresh pod starts at 42-51 MB — already 66-80% of the cap — and crosses 64 MB in 2-3 days, because the two
ConcurrentHashMaptimer caches (RequestMetrics.kt:27-33,BlockchainRpc.kt:216-219) never evict and themethodtag keeps growing. 24.7% of label sets have recorded exactly one observation in ten days, and each still costs 10.10 KB on every scrape.For context on why this got acute recently: #863 added the
methodtag toupstream.rpc.conn. That is a genuinely useful label, but it took that family from ~199 label sets (upstream × chain) to 4,360, multiplying a 69-bucket histogram by ~22×, which is roughly +43 MB of body. This PR keeps the label and cuts the buckets instead.What this changes
A
MeterFilterinMonitoringSetupreplaces the percentile histogram with 12 explicit boundaries — 1ms, 2.5ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 5s, 30s — so 13 buckets including+Inf. One filter covers all sixpublishPercentileHistogram()call sites (BasicHttpFactory.kt:43,BlockchainRpc.kt:204/213/230,WsConnectionFactory.kt:40,GrpcUpstreams.kt:292), and a new call site cannot forget it.Effect on the same pod: 74.5 MB → ~16 MB, a 5.3× cut on 94.7% of the body.
The filter is deliberately narrow. It returns the incoming config untouched unless the meter is a
TIMERand already asked for a percentile histogram, so:executor.seconds,request.jsonrpc.calland friends stay bucket-free);Values are set in nanoseconds, which is what
DistributionStatisticConfigexpects for timers — the same conversionTimer.Builder.serviceLevelObjectives(Duration...)does internally viaDuration::toNanos.merge(config)is ordered so the builder's values win and everything else is inherited.Why 13 buckets and not 20
Sizing against today's 74.5 MB gives the wrong answer. The ceiling matters: dshackle has no per-user label anywhere (all 22 label names checked), and
upstreamis strictly 1:1 withchain, so the worst case isΣ over upstreams of allowlist(chain)— 203 upstreams × 52 upstream-callable methods for a plain EVM chain, plus 788 chain-specific extras. That is 11,344 label sets onupstream.rpc.connand 13,199 onrequest.grpc.native.response, i.e. a ~240 MB ceiling at 69 buckets.A 20-bucket ladder would fix today and break again later. 13 leaves real headroom.
What this does not change
_sum,_count,_maxare untouched.histogram_quantileconsumers keep working at coarser resolution. Eight panels across two dashboards depend on these buckets, which is why dropping the bucket families outright was rejected.monitoring.extendedis unrelated. It gates four small extras (no_matching_upstream,native_call_failure, theFilteredApissummaries,ProxyServer.ExtendedRequestMetrics) and never touchedpublishPercentileHistogram().Still worth doing separately
This PR caps the per-label-set cost. It does not stop label sets accumulating, so the body still grows — about 4.6× slower. Two follow-ups, deliberately out of scope here:
BlockchainRpc.kt:78-81builds a timer from the caller-supplieditem.methodon the request path with no validation, and that cache never evicts. Observed values are all real method names today, so there is no evidence of a problem in practice, but nothing in dshackle bounds it.Testing
MonitoringSetupSpeccovers the filter against a localPrometheusMeterRegistry, so it asserts on real scrape output rather than on config objects:DistributionSummarywith a percentile histogram does not get the latency ladder.I could not compile or run this locally — there is no JDK 21 on the machine I worked from, so I am relying on CI (
make test, which runs./gradlew checkincluding ktlint) for compile and test verification. Every Micrometer API used here was checked against the publishedmicrometer-core:1.16.5sources rather than from memory:MeterFilter.configurereceives the post-mapid,serviceLevelObjectivestakesdouble...in nanoseconds for timers,mergelets the receiver win, and withpercentilesHistogram(false)only the SLO boundaries become buckets with no min/max clamping.Verification after deploy