Implement metricTtl for prometheus-emitter#18598
Conversation
Thanks for the review @kfaraz! This is the ingest/kafka/partitionlag metric being emitted by 2 Coordinators. The active one is emitting the proper metric (0) but the previous Coordinator is emitting a stale metric that doesn't get reset until we manually restart it. The scenario we run into is that if a leader change occurs while there is lag on a topic then the old Coordinator continues to emit that stale lag metric. We have some alerting set up for lag, so the stale value ends up triggering false alarms.
|
kfaraz
left a comment
There was a problem hiding this comment.
Looks good, left some final suggestions.
|
|
||
| public boolean isExpired(long ttlSeconds) | ||
| { | ||
| return updateTimer.hasElapsed(new Duration(TimeUnit.SECONDS.toMillis(ttlSeconds))); |
There was a problem hiding this comment.
| return updateTimer.hasElapsed(new Duration(TimeUnit.SECONDS.toMillis(ttlSeconds))); | |
| return updateTimer.hasElapsed(Duration.standardSeconds(ttlSeconds)); |
| // Start TTL scheduler if TTL is configured | ||
| if (config.getFlushPeriod() != null) { | ||
| exec = ScheduledExecutors.fixed(1, "PrometheusTTLExecutor-%s"); | ||
| // Check TTL every minute |
There was a problem hiding this comment.
I don't think this comment is valid anymore, please remove it.
| config.getFlushPeriod(), | ||
| TimeUnit.SECONDS | ||
| ); | ||
| log.info("Started TTL scheduler with TTL of %d seconds", config.getFlushPeriod()); |
There was a problem hiding this comment.
| log.info("Started TTL scheduler with TTL of %d seconds", config.getFlushPeriod()); | |
| log.info("Started TTL scheduler with TTL of [%d] seconds.", config.getFlushPeriod()); |
| updateTimer.start(); | ||
| } | ||
|
|
||
| public long getTimeSinceLastUpdate() |
There was a problem hiding this comment.
| public long getTimeSinceLastUpdate() | |
| public long getMillisSinceLastUpdate() |
| Assert.assertFalse("Metric should not be expired initially", | ||
| testMetric.isExpired(flushPeriod)); |
There was a problem hiding this comment.
Style: cleaner to put the first arg in a new line. Please use the same style in other places too.
| Assert.assertFalse("Metric should not be expired initially", | |
| testMetric.isExpired(flushPeriod)); | |
| Assert.assertFalse( | |
| "Metric should not be expired initially", | |
| testMetric.isExpired(flushPeriod) | |
| ); |
| log.debug("Metric [%s] has expired (last updated %d ms ago)", | ||
| entry.getKey(), | ||
| entry.getValue().getTimeSinceLastUpdate()); |
There was a problem hiding this comment.
Style:
| log.debug("Metric [%s] has expired (last updated %d ms ago)", | |
| entry.getKey(), | |
| entry.getValue().getTimeSinceLastUpdate()); | |
| log.debug( | |
| "Metric[%s] has expired as it was last updated [%d] ms ago.", | |
| entry.getKey(), | |
| entry.getValue().getTimeSinceLastUpdate() | |
| ); |
abhishekrb19
left a comment
There was a problem hiding this comment.
Thanks @aho135 for the feature, this is very useful! I've left some questions on the TTL-based tracking and some misc suggestions.
| if (Objects.nonNull(flushPeriod)) { | ||
| Preconditions.checkArgument(flushPeriod > 0, "flushPeriod must be greater than 0."); | ||
| } |
There was a problem hiding this comment.
- Would be good to include the invalid
flushPeriodin the error message - Looks like the other validation uses this to, could use
DruidExceptionfor newer ones. Something like:
throw DruidException.forPersona(DruidException.Persona.OPERATOR)
.ofCategory(DruidException.Category.INVALID_INPUT)
.build(
"Invalid value for 'druid.emitter.prometheus.flushPeriod'[%s] specified, flushPeriod must be > 0 if specified.",
waitForShutdownDelay
)
);
| | `druid.emitter.prometheus.addServiceAsLabel` | Flag to include the druid service name (e.g. `druid/broker`, `druid/coordinator`, etc.) as a prometheus label. | no | false | | ||
| | `druid.emitter.prometheus.pushGatewayAddress` | Pushgateway address. Required if using `pushgateway` strategy. | no | none | | ||
| | `druid.emitter.prometheus.flushPeriod` | Emit metrics to Pushgateway every `flushPeriod` seconds. Required if `pushgateway` strategy is used. | no | 15 | | ||
| | `druid.emitter.prometheus.flushPeriod` | If strategy is `pushgateway`, emits metrics every `flushPeriod` seconds. Required if `pushgateway` strategy is used. If strategy is `exporter`, configures the metric ttl such that if the metric value is not updated within the `flushPeriod` then it will stop being emitted. Optional if `exporter` strategy is used. | no | 15 seconds for `pushgateway` strategy. None for `exporter` strategy | |
There was a problem hiding this comment.
Thanks for the docs! Minor suggestion:
| | `druid.emitter.prometheus.flushPeriod` | If strategy is `pushgateway`, emits metrics every `flushPeriod` seconds. Required if `pushgateway` strategy is used. If strategy is `exporter`, configures the metric ttl such that if the metric value is not updated within the `flushPeriod` then it will stop being emitted. Optional if `exporter` strategy is used. | no | 15 seconds for `pushgateway` strategy. None for `exporter` strategy | | |
| | `druid.emitter.prometheus.flushPeriod` | If strategy is `pushgateway`, metrics are emitted every `flushPeriod` seconds. Required if `pushgateway` strategy is used. If strategy is `exporter`, this configures the metric TTL such that if the metric value is not updated within the `flushPeriod` seconds, then it will stop being emitted. Optional if `exporter` strategy is used. | no | 15 seconds for `pushgateway` strategy. None for `exporter` strategy | |
Also, do you have guidance on what an operator should set this value to for TTL eviction (perhaps something as a function of emission period)?
There was a problem hiding this comment.
Yeah, good point. I guess it would have to account for the scraping frequency too.
So, a good value would be something like 3 * scrape period or 3 * emission period, whichever is larger.
| // Start TTL scheduler if TTL is configured | ||
| if (config.getFlushPeriod() != null) { | ||
| exec = ScheduledExecutors.fixed(1, "PrometheusTTLExecutor-%s"); | ||
| // Check TTL every minute |
There was a problem hiding this comment.
I think this comment is stale?
|
|
||
| Map<String, DimensionsAndCollector> map = metrics.getRegisteredMetrics(); | ||
| for (Map.Entry<String, DimensionsAndCollector> entry : map.entrySet()) { | ||
| if (entry.getValue().isExpired(config.getFlushPeriod())) { |
There was a problem hiding this comment.
Extract entry.getValue() to a variable and reuse since it's used a few times in this loop :)
|
|
||
| Map<String, DimensionsAndCollector> map = metrics.getRegisteredMetrics(); | ||
| for (Map.Entry<String, DimensionsAndCollector> entry : map.entrySet()) { | ||
| if (entry.getValue().isExpired(config.getFlushPeriod())) { |
There was a problem hiding this comment.
DimensionsAndCollector could accept the flush period directly, initialized from the Prometheus config that’s plumbed through Metrics. Then this code could become if (entry.getValue().isExpired()) {} where this fixed config isn't redundantly passed in
| log.debug("Metric [%s] has expired (last updated %d ms ago)", | ||
| entry.getKey(), | ||
| entry.getValue().getTimeSinceLastUpdate()); | ||
| entry.getValue().getCollector().clear(); |
There was a problem hiding this comment.
Is this clear() operation on SimpleCollector thread-safe? Doing a quick scan of the docs suggest that the client libraries must be thread-safe, but wanted to confirm that clearing can concurrently happen with the metric updates in emitMetric()
Side note: I think it would be better to move some of these methods inside DimensionsAndCollector to avoid abstraction leaks. But that refactor can be done later since it'd also require moving the metric updates from emitMetric() into this class.
There was a problem hiding this comment.
Yup, confirmed that the Map getting cleared is a ConcurrentHashMap
| updateTimer.reset(); | ||
| updateTimer.start(); |
There was a problem hiding this comment.
nit: can be simplified to updateTimer.restart();
| config.getFlushPeriod(), | ||
| TimeUnit.SECONDS | ||
| ); | ||
| log.info("Started TTL scheduler with TTL of %d seconds", config.getFlushPeriod()); |
There was a problem hiding this comment.
| log.info("Started TTL scheduler with TTL of %d seconds", config.getFlushPeriod()); | |
| log.info("Started TTL scheduler with TTL of [%d] seconds", config.getFlushPeriod()); |
| log.debug("Metric [%s] has expired (last updated %d ms ago)", | ||
| entry.getKey(), | ||
| entry.getValue().getTimeSinceLastUpdate()); | ||
| entry.getValue().getCollector().clear(); |
There was a problem hiding this comment.
It looks like clear() removes all children for the metric. Do we instead want to use remove() to target a specific time series by specifying the corresponding set of label values?
Without that, metrics that track things like task ID or query ID, for example, task/run/time or query/wait/time mentioned in #14638 wouldn’t get evicted if they’re being updated frequently.
When we call metric.resetLastUpdateTime(), should we pass the appropriate label set and track it? Then in cleanUpStaleMetrics(), we could check the label sets and clear only the stale time series rather than clearing all of them or none at all. What do you think?
There was a problem hiding this comment.
Hmm, I suppose removing the children for specific labels would be a more complete solution.
But it could blow up the memory usage pretty fast since we would now be tracking the last updated time for every unique set of label values for every metric.
I think the issue this PR is trying to address is that if a certain metric stops being reported by a service
altogether (irrespective of label values), it should not remain in the collector anymore. e.g. in leadership changes.
Also, the staleness concern should really only apply to gauge metrics. So if timer (histogram) metrics like task/run/time and query/wait/time are still being updated by the current service (for any label), we should continue emitting them. Getting rid of the expired labels becomes more of a mem usage concern than anything else.
But with gauge metrics like say segment/size (which represents bytes of segment available on a historical for a given datasource), I agree that the story is different.
If all segments of a datasource are removed from a historical, we should definitely stop reporting segment/size for that datasource.
But I would suggest we add a comment and postpone the full solution for gauge metrics for a later PR.
Ideally, we would want to write up an embedded test (using Prometheus testcontainer) and verify the behaviour of gauge metrics in such cases and then proceed with a solution.
What are your thoughts, @abhishekrb19 , @aho135 ?
There was a problem hiding this comment.
Hmm, I suppose removing the children for specific labels would be a more complete solution.
But it could blow up the memory usage pretty fast since we would now be tracking the last updated time for every unique set of label values for every metric.
Yeah, good callout on the memory usage when tracking every unique combination of label sets per metric. Prometheus isn’t designed for very high cardinality labels, and the Prometheus SimpleCollector in the java library already tracks the unique label sets (children) internally. On the extension side, we’d only be dealing with a subset of those children, since we’d remove the ephemeral “stale” entries anyway (if there's a way to leverage the collector’s internal state somehow, that would be even better). So I’m not too concerned about the additional memory this would require, though it might still be worth validating in a cluster with some labels with reasonable cardinality and load.
I’m also okay with the suggestion to start without labels for now and revisit this (or another solution) once we have a better understanding of the additional memory overhead it might introduce. The current implementation should remain extensible enough to support label tracking in the future if needed. We can keep #14638
open and call out in the documentation that the TTL expiration only applies to certain metric types and not to individual timeseries'.
@aho135 @kfaraz what do you think?
There was a problem hiding this comment.
Yeah, I was also concerned about memory usage. Especially where folks are adding queryId as a dimension like in #14638.
Sounds good to me to tackle the label problem in a separate PR. We can say that this PR specifically targets resetting the metric value in the leader election scenario
| ServiceMetricEvent event = ServiceMetricEvent.builder() | ||
| .setMetric("segment/loadQueue/count", 10) | ||
| .build(ImmutableMap.of("service", "historical", "host", "druid.test.cn")); | ||
| emitter.emit(event); |
There was a problem hiding this comment.
Could you add the same metric with different label values so that it actrs as a different timeseries and then verify that one exists while the other one has expired?
There was a problem hiding this comment.
I guess we can defer this test since we're not considering labels when clearing in this PR
Co-authored-by: Kashif Faraz <[email protected]>
abhishekrb19
left a comment
There was a problem hiding this comment.
LGTM, some minor comments. Thanks, @aho135!
| private final Integer ttlSeconds; | ||
|
|
||
| DimensionsAndCollector(String[] dimensions, SimpleCollector collector, double conversionFactor, double[] histogramBuckets) | ||
| DimensionsAndCollector(String[] dimensions, SimpleCollector collector, double conversionFactor, double[] histogramBuckets, Integer ttlSeconds) |
There was a problem hiding this comment.
| DimensionsAndCollector(String[] dimensions, SimpleCollector collector, double conversionFactor, double[] histogramBuckets, Integer ttlSeconds) | |
| DimensionsAndCollector(String[] dimensions, SimpleCollector collector, double conversionFactor, double[] histogramBuckets, @Nullable Integer ttlSeconds) |
| } | ||
|
|
||
| public Metrics(String namespace, String path, boolean isAddHostAsLabel, boolean isAddServiceAsLabel, Map<String, String> extraLabels) | ||
| public Metrics(String namespace, String path, boolean isAddHostAsLabel, boolean isAddServiceAsLabel, Map<String, String> extraLabels, Integer ttlSeconds) |
There was a problem hiding this comment.
| public Metrics(String namespace, String path, boolean isAddHostAsLabel, boolean isAddServiceAsLabel, Map<String, String> extraLabels, Integer ttlSeconds) | |
| public Metrics(String namespace, String path, boolean isAddHostAsLabel, boolean isAddServiceAsLabel, Map<String, String> extraLabels, @Nullable Integer ttlSeconds) |
| this.conversionFactor = conversionFactor; | ||
| this.histogramBuckets = histogramBuckets; | ||
| this.updateTimer = Stopwatch.createStarted(); | ||
| this.ttlSeconds = ttlSeconds; |
There was a problem hiding this comment.
nit: you could assign Duration.standardSeconds(ttlSeconds) in the constructor itself
| tableSpec | ||
| TableSpec | ||
|
|
||
| - ../docs/development/extensions-contrib/prometheus.md |
There was a problem hiding this comment.
I think this isn't needed?
| - ../docs/development/extensions-contrib/prometheus.md |
There was a problem hiding this comment.
I was just copying the current format. Ex: https://github.com/apache/druid/blob/master/website/.spelling#L2455
I can remove this if it's not needed though
| | `druid.emitter.prometheus.addServiceAsLabel` | Flag to include the druid service name (e.g. `druid/broker`, `druid/coordinator`, etc.) as a prometheus label. | no | false | | ||
| | `druid.emitter.prometheus.pushGatewayAddress` | Pushgateway address. Required if using `pushgateway` strategy. | no | none | | ||
| | `druid.emitter.prometheus.flushPeriod` | Emit metrics to Pushgateway every `flushPeriod` seconds. Required if `pushgateway` strategy is used. | no | 15 | | ||
| | `druid.emitter.prometheus.flushPeriod` | When using the `pushgateway` strategy metrics are emitted every `flushPeriod` seconds. <br/>When using the `exporter` strategy this configures the metric TTL such that if the metric value is not updated within `flushPeriod` seconds then it will stop being emitted. It is recommended to set this to at least 3 * `scrape_interval`. | Required if `pushgateway` strategy is used, optional otherwise. | 15 seconds for `pushgateway` strategy. <br/>None for `exporter` strategy. | |
There was a problem hiding this comment.
An additional note on the behavior for clarity:
| | `druid.emitter.prometheus.flushPeriod` | When using the `pushgateway` strategy metrics are emitted every `flushPeriod` seconds. <br/>When using the `exporter` strategy this configures the metric TTL such that if the metric value is not updated within `flushPeriod` seconds then it will stop being emitted. It is recommended to set this to at least 3 * `scrape_interval`. | Required if `pushgateway` strategy is used, optional otherwise. | 15 seconds for `pushgateway` strategy. <br/>None for `exporter` strategy. | | |
| | `druid.emitter.prometheus.flushPeriod` | When using the `pushgateway` strategy metrics are emitted every `flushPeriod` seconds. <br/>When using the `exporter` strategy this configures the metric TTL such that if the metric value is not updated within `flushPeriod` seconds then it will stop being emitted. Note that unique label combinations per metric are currently not subject to TTL expiration. It is recommended to set this to at least 3 * `scrape_interval`. | Required if `pushgateway` strategy is used, optional otherwise. | 15 seconds for `pushgateway` strategy. <br/>None for `exporter` strategy. | |
kfaraz
left a comment
There was a problem hiding this comment.
LGTM
Left some nitpicks which are suggestions for later clean up, not needed in this PR.
| private final double conversionFactor; | ||
| private final double[] histogramBuckets; | ||
| private final Stopwatch updateTimer; | ||
| private final Integer ttlSeconds; |
There was a problem hiding this comment.
Nit: This can be a Duration to avoid repeated created of a Duration object in isExpired.
| @@ -74,7 +74,8 @@ public PrometheusEmitter(PrometheusEmitterConfig config) | |||
| config.getDimensionMapPath(), | |||
| config.isAddHostAsLabel(), | |||
| config.isAddServiceAsLabel(), | |||
| config.getExtraLabels() | |||
| config.getExtraLabels(), | |||
| config.getFlushPeriod() | |||
There was a problem hiding this comment.
Nit: we could pass the entire config object to the constructor rather than these 6 args.
| if (Objects.nonNull(flushPeriod)) { | ||
| if (flushPeriod <= 0) { | ||
| throw DruidException.forPersona(DruidException.Persona.OPERATOR) | ||
| .ofCategory(DruidException.Category.INVALID_INPUT) | ||
| .build( | ||
| StringUtils.format( | ||
| "Invalid value for flushPeriod[%s] specified, flushPeriod must be > 0.", | ||
| flushPeriod | ||
| ) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Nit: seems like this part can be commoned out.
|
Thanks for the review @abhishekrb19 @kfaraz I went ahead and made those updates |
| if (ttlSeconds == null) { | ||
| log.error("Invalid usage of isExpired(), TTL has not been set"); | ||
| return false; |
There was a problem hiding this comment.
This condition should never trigger in production. if it does, it’s a bug, so we could throw DruidException.defensive() to indicate invalid usage for developers. Given it’s configured correctly now, this should be fine and we can update this later.
…er (apache#18598) Partially addresses apache#14638. This implementation adds flushPeriod to the exporter strategy in the prometheus-emitter, which when configured, will stop emitting the metric if it has not been updated within the TTL. This does not account for scenarios where the metric is being emitted but with different label values. This PR can be enhanced in the future to track all label combinations.
…er (#18598) Partially addresses #14638. This implementation adds flushPeriod to the exporter strategy in the prometheus-emitter, which when configured, will stop emitting the metric if it has not been updated within the TTL. This does not account for scenarios where the metric is being emitted but with different label values. This PR can be enhanced in the future to track all label combinations.
…er (apache#18598) Partially addresses apache#14638. This implementation adds flushPeriod to the exporter strategy in the prometheus-emitter, which when configured, will stop emitting the metric if it has not been updated within the TTL. This does not account for scenarios where the metric is being emitted but with different label values. This PR can be enhanced in the future to track all label combinations.
Fixes apache#14638 This is an extension of apache#18598 to enhance the TTL to track metrics at the label level instead of just the metric level. This should allow operators to configure medium cardinality dimensions on the prometheus-emitter without putting pressure on the Prometheus collector/sub-system.

Partially addresses #14638. This implementation will stop emitting the metric if it has not been updated within the TTL. This does not account for scenarios where the metric is being emitted but with different label values. This PR can be enhanced in the future to track all label combinations
Description
This PR adds flushPeriod for the exporter strategy which defines the amount of time a metric can exist without being updated before the value is cleared. Prometheus is unique amongst emitters as it utilizes a pull model instead of a push model. This leads to an issue with stale metrics. For example, if the active Coordinator leader is emitting ingest/kafka/lag but then a leader election change occurs, the Coordinator would continue to emit that stale metric value until it is restarted.
An alternative approach is to reset the metric values within DruidCoordinator. There we have access to the ServiceEmitter. We could update the Emitter interface to have a clear() method that is called in stopBeingLeader. For most emitters though, this method would do nothing since they use a push model. Instead of muddying the interface I thought it better to implement this in prometheus-emitter itself
Release note
Adds flushPeriod to the
exporterstrategy in the prometheus-emitter which allows the operator to specify the TTL for a metric. If a metric value has not been updated within the TTL period then the value will stop being emitted.Key changed/added classes in this PR
PrometheusEmitterPrometheusEmitterConfigDimensionsAndCollectorPrometheusEmitterConfigTestPrometheusEmitterTestThis PR has: