This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/storm.git


The following commit(s) were added to refs/heads/master by this push:
     new 7ee031e91 Introduce jitter metric based on RFC 1889 Appendix A (#8586)
7ee031e91 is described below

commit 7ee031e91e0760eb173794efaa35d904e18f5129
Author: Gianluca Graziadei <[email protected]>
AuthorDate: Mon May 18 21:18:46 2026 +0200

    Introduce jitter metric based on RFC 1889 Appendix A (#8586)
    
    * init
    
    * allocate suppliers once
    
    * improve jitter definition
    
    * add documentation
    
    * format
    
    * format
    
    * fix checkstyle
    
    * fix RFC 1889 jitter definition, guarantee jitter decay if stable latency
    
    * minor changes
---
 conf/defaults.yaml                                 |  48 +-
 docs/Metrics.md                                    |  42 +-
 storm-client/src/jvm/org/apache/storm/Config.java  | 102 +++--
 .../jvm/org/apache/storm/metrics2/EwmaGauge.java   |  84 ++++
 .../jvm/org/apache/storm/metrics2/TaskMetrics.java |  89 +++-
 .../jvm/org/apache/storm/utils/ConfigUtils.java    |  23 +
 .../jvm/org/apache/storm/utils/ObjectReader.java   |   2 +
 .../apache/storm/validation/ConfigValidation.java  |  18 +
 .../jvm/org/apache/storm/TestConfigValidate.java   |  28 ++
 .../org/apache/storm/metrics2/EwmaGaugeTest.java   | 347 +++++++++++++++
 .../org/apache/storm/metrics2/TaskMetricsTest.java | 485 +++++++++++++++++++++
 11 files changed, 1195 insertions(+), 73 deletions(-)

diff --git a/conf/defaults.yaml b/conf/defaults.yaml
index 436809972..bc9a5979d 100644
--- a/conf/defaults.yaml
+++ b/conf/defaults.yaml
@@ -25,7 +25,7 @@ java.library.path: 
"/usr/local/lib:/opt/local/lib:/usr/lib:/usr/lib64"
 storm.local.dir: "storm-local"
 storm.log4j2.conf.dir: "log4j2"
 storm.zookeeper.servers:
-    - "localhost"
+  - "localhost"
 storm.zookeeper.port: 2181
 storm.zookeeper.root: "/storm"
 storm.zookeeper.session.timeout: 20000
@@ -52,7 +52,7 @@ storm.nimbus.retry.intervalceiling.millis: 60000
 storm.nimbus.zookeeper.acls.check: true
 storm.nimbus.zookeeper.acls.fixup: true
 
-storm.auth.simple-white-list.users: []
+storm.auth.simple-white-list.users: [ ]
 storm.cluster.state.store: "org.apache.storm.cluster.ZKStateStorageFactory"
 storm.meta.serialization.delegate: 
"org.apache.storm.serialization.GzipThriftSerializationDelegate"
 storm.codedistributor.class: 
"org.apache.storm.codedistributor.LocalFileSystemCodeDistributor"
@@ -62,7 +62,7 @@ storm.health.check.timeout.ms: 5000
 storm.disable.symlinks: false
 
 ### nimbus.* configs are for the master
-nimbus.seeds : ["localhost"]
+nimbus.seeds: [ "localhost" ]
 nimbus.thrift.port: 6627
 nimbus.thrift.threads: 64
 nimbus.thrift.max_buffer_size: 1048576
@@ -163,10 +163,10 @@ storm.blobstore.acl.validation.enabled: false
 ### supervisor.* configs are for node supervisors
 # Define the amount of workers that can be run on this machine. Each worker is 
assigned a port to use for communication
 supervisor.slots.ports:
-    - 6700
-    - 6701
-    - 6702
-    - 6703
+  - 6700
+  - 6701
+  - 6702
+  - 6703
 supervisor.childopts: "-Xmx256m"
 supervisor.run.worker.as.user: false
 #how long supervisor will wait to ensure that a worker process is started
@@ -184,8 +184,8 @@ supervisor.worker.heartbeats.max.timeout.secs: 600
 #For topology configurable heartbeat timeout, maximum allowed heartbeat 
timeout.
 worker.max.timeout.secs: 600
 supervisor.enable: true
-supervisor.supervisors: []
-supervisor.supervisors.commands: []
+supervisor.supervisors: [ ]
+supervisor.supervisors.commands: [ ]
 supervisor.memory.capacity.mb: 4096.0
 #By convention 1 cpu core should be about 100, but this can be adjusted if 
needed
 # using 100 makes it simple to set the desired value to the capacity 
measurement
@@ -278,6 +278,8 @@ topology.max.task.parallelism: null
 topology.max.spout.pending: null    # ideally should be larger than 
topology.producer.batch.size. (esp. if topology.batch.flush.interval.millis=0)
 topology.state.synchronization.timeout.secs: 60
 topology.stats.sample.rate: 0.05
+topology.stats.ewma.enable: false
+topology.stats.ewma.smoothing.factor: 0.0625
 topology.builtin.metrics.bucket.size.secs: 60
 topology.fall.back.on.java.serialization: false
 topology.worker.childopts: null
@@ -287,16 +289,16 @@ topology.worker.shared.thread.pool.size: 4
 
 # Spout Wait Strategy - employed when there is no data to produce
 topology.spout.wait.strategy: "org.apache.storm.policy.WaitStrategyProgressive"
-topology.spout.wait.park.microsec : 100          # park time for 
org.apache.storm.policy.WaitStrategyPark. Busy spins if set to 0.
+topology.spout.wait.park.microsec: 100          # park time for 
org.apache.storm.policy.WaitStrategyPark. Busy spins if set to 0.
 
 topology.spout.wait.progressive.level1.count: 0          # number of 
iterations to spend in level 1 [no sleep] of WaitStrategyProgressive, before 
progressing to level 2
 topology.spout.wait.progressive.level2.count: 0          # number of 
iterations to spend in level 2 [parkNanos(1)] of WaitStrategyProgressive, 
before progressing to level 3
 topology.spout.wait.progressive.level3.sleep.millis: 1   # sleep duration for 
idling iterations in level 3 of WaitStrategyProgressive
 
 # Bolt Wait Strategy - employed when there is no data in its receive buffer to 
process
-topology.bolt.wait.strategy : "org.apache.storm.policy.WaitStrategyProgressive"
+topology.bolt.wait.strategy: "org.apache.storm.policy.WaitStrategyProgressive"
 
-topology.bolt.wait.park.microsec : 100          # park time for 
org.apache.storm.policy.WaitStrategyPark. Busy spins if set to 0.
+topology.bolt.wait.park.microsec: 100          # park time for 
org.apache.storm.policy.WaitStrategyPark. Busy spins if set to 0.
 
 topology.bolt.wait.progressive.level1.count: 1          # number of iterations 
to spend in level 1 [no sleep] of WaitStrategyProgressive, before progressing 
to level 2
 topology.bolt.wait.progressive.level2.count: 1000       # number of iterations 
to spend in level 2 [parkNanos(1)] of WaitStrategyProgressive, before 
progressing to level 3
@@ -363,7 +365,7 @@ 
blacklist.scheduler.assume.supervisor.bad.based.on.bad.slot: true
 
 dev.zookeeper.path: "/tmp/dev-storm-zookeeper"
 
-pacemaker.servers: []
+pacemaker.servers: [ ]
 pacemaker.port: 6699
 pacemaker.base.threads: 10
 pacemaker.max.threads: 50
@@ -371,12 +373,12 @@ pacemaker.client.max.threads: 2
 pacemaker.thread.timeout: 10
 pacemaker.childopts: "-Xmx1024m"
 pacemaker.auth.method: "NONE"
-pacemaker.kerberos.users: []
+pacemaker.kerberos.users: [ ]
 pacemaker.thrift.message.size.max: 10485760
 
 #default storm daemon metrics reporter plugins
 storm.daemon.metrics.reporter.plugins:
-     - "org.apache.storm.daemon.metrics.reporters.JmxPreparableReporter"
+  - "org.apache.storm.daemon.metrics.reporters.JmxPreparableReporter"
 storm.daemon.metrics.reporter.interval.secs: 10
 
 storm.metricstore.class: "org.apache.storm.metricstore.rocksdb.RocksDbStore"
@@ -399,8 +401,8 @@ storm.cgroup.inherit.cpuset.configs: false
 # Configs for CGroup support
 storm.cgroup.hierarchy.dir: "/cgroup/storm_resources"
 storm.cgroup.resources:
-    - "cpu"
-    - "memory"
+  - "cpu"
+  - "memory"
 storm.cgroup.hierarchy.name: "storm"
 storm.supervisor.cgroup.rootdir: "storm"
 storm.cgroup.cgexec.cmd: "/bin/cgexec"
@@ -419,12 +421,12 @@ storm.worker.min.cpu.pcore.percent: 0.0
 
 storm.topology.classpath.beginning.enabled: false
 worker.metrics:
-    "CGroupMemory": "org.apache.storm.metrics2.cgroup.CGroupMemoryUsage"
-    "CGroupMemoryLimit": "org.apache.storm.metrics2.cgroup.CGroupMemoryLimit"
-    "CGroupCpu": "org.apache.storm.metrics2.cgroup.CGroupCpu"
-    "CGroupCpuGuarantee": "org.apache.storm.metrics2.cgroup.CGroupCpuGuarantee"
-    "CGroupCpuGuaranteeByCfsQuota": 
"org.apache.storm.metrics2.cgroup.CGroupCpuGuaranteeByCfsQuota"
-    "CGroupCpuStat": "org.apache.storm.metrics2.cgroup.CGroupCpuStat"
+  "CGroupMemory": "org.apache.storm.metrics2.cgroup.CGroupMemoryUsage"
+  "CGroupMemoryLimit": "org.apache.storm.metrics2.cgroup.CGroupMemoryLimit"
+  "CGroupCpu": "org.apache.storm.metrics2.cgroup.CGroupCpu"
+  "CGroupCpuGuarantee": "org.apache.storm.metrics2.cgroup.CGroupCpuGuarantee"
+  "CGroupCpuGuaranteeByCfsQuota": 
"org.apache.storm.metrics2.cgroup.CGroupCpuGuaranteeByCfsQuota"
+  "CGroupCpuStat": "org.apache.storm.metrics2.cgroup.CGroupCpuStat"
 
 # The number of buckets for running statistics
 num.stat.buckets: 20
diff --git a/docs/Metrics.md b/docs/Metrics.md
index 580d66e9b..8d620f0da 100644
--- a/docs/Metrics.md
+++ b/docs/Metrics.md
@@ -180,15 +180,22 @@ Similar to the tuple counting metrics storm also collects 
average latency metric
 
 ##### `__complete-latency`
 
-The complete latency is just for spouts.  It is the average amount of time it 
took for `ack` or `fail` to be called for a tuple after it was emitted.  If 
acking is disabled this metric is likely to be blank or 0 for all values, and 
should be ignored.
+The complete latency is just for spouts. It is the average amount of time it 
took for `ack` or `fail` to be called for a
+tuple after it was emitted. If acking is disabled this metric is likely to be 
blank or 0 for all values, and should be
+ignored.
 
 ##### `__execute-latency`
 
-This is just for bolts.  It is the average amount of time that the bolt spent 
in the call to the `execute` method.  The higher this gets, the lower the 
throughput of tuples per bolt instance.
+This is just for bolts. It is the average amount of time that the bolt spent 
in the call to the `execute` method. The
+higher this gets, the lower the throughput of tuples per bolt instance.
 
 ##### `__process-latency`
 
-This is also just for bolts.  It is the average amount of time between when 
`execute` was called to start processing a tuple, to when it was acked or 
failed by the bolt.  If your bolt is a very simple bolt and the processing is 
synchronous then `__process-latency` and `__execute-latency` should be very 
close to one another, with process latency being slightly smaller.  If you are 
doing a join or have asynchronous processing then it may take a while for a 
tuple to be acked so the process  [...]
+This is also just for bolts. It is the average amount of time between when 
`execute` was called to start processing a
+tuple, to when it was acked or failed by the bolt. If your bolt is a very 
simple bolt and the processing is synchronous
+then `__process-latency` and `__execute-latency` should be very close to one 
another, with process latency being
+slightly smaller. If you are doing a join or have asynchronous processing then 
it may take a while for a tuple to be
+acked so the process latency would be higher than the execute latency.
 
 ##### `__skipped-max-spout-ms`
 
@@ -207,6 +214,35 @@ This metric indicates the overflow count last time BP 
status was sent, with a mi
 
 This metric records how much time a spout was idle because the topology was 
deactivated.  This is the total time in milliseconds, not the average amount of 
time and is not sub-sampled.
 
+#### Tuple Jitter Metrics
+To activate jitter-based metrics, `topology.stats.ewma.enable` must be set to 
`true`, which switches the system to a jitter estimation based on an 
Exponential Moving Average (EWMA).
+In this model, jitter is dynamically updated by weighting new latency samples 
against historical data using a smoothing factor 
(`topology.stats.ewma.smoothing.factor`).
+This parameter, which defaults to 0.0625 (equivalent to $1/16$ or a 4-bit 
right shift), determines the metric's reactivity: higher values make the jitter 
more sensitive to recent spikes, while lower values prioritize long-term 
stability.
+Operators should be aware that enabling this feature triples the gauge count 
for every component-stream pair per task; this significant increase in metric 
cardinality can impact TSDB storage and costs, so backend capacity should be 
verified before deployment.
+
+##### `__complete-jitter`
+
+This metric is specific to spouts. It measures the variation (jitter) in the 
total completion time (end-to-end latency)
+of tuples, calculated using the exponentially weighted moving average (EWMA) 
algorithm as defined in RFC 1889 §A.8 / RFC 3550 §A.8.
+While `__complete-latency` indicates the average amount of time it took for a 
tuple to be fully processed by the
+topology (from emission to the final ack), the jitter metric quantifies the 
consistency of that process. If acking is
+disabled, this metric is likely to be blank or 0 and should be ignored.
+
+##### `__execute-jitter`
+
+This metric is specific to bolts. It measures the variation (jitter) in the 
time spent within the execute method,
+calculated using the exponentially weighted moving average (EWMA) algorithm as 
defined in RFC 1889 §A.8 / RFC 3550 §A.8.
+While `__execute-latency` provides the average time spent in the execute call, 
the jitter metric quantifies the
+predictability of that execution time. It is a critical indicator of 
computational "smoothness".
+
+##### `__process-jitter`
+
+This metric is specific to bolts. It measures the variation (jitter) in the 
process latency, calculated using the
+exponentially weighted moving average (EWMA) algorithm as defined in RFC 1889 
§A.8 / RFC 3550 §A.8.
+While `__process-latency` provides the average time a tuple spends being 
processed, the jitter metric quantifies the
+stability of that processing time. It helps identify "noisy" execution 
environments where processing times fluctuate
+significantly, even if the average remains within acceptable limits.
+
 #### Error Reporting Metrics
 
 Storm also collects error reporting metrics for bolts and spouts.
diff --git a/storm-client/src/jvm/org/apache/storm/Config.java 
b/storm-client/src/jvm/org/apache/storm/Config.java
index e332b726b..62770b62e 100644
--- a/storm-client/src/jvm/org/apache/storm/Config.java
+++ b/storm-client/src/jvm/org/apache/storm/Config.java
@@ -350,13 +350,13 @@ public class Config extends HashMap<String, Object> {
      * <p>comp-1 cannot exist on same worker as comp-2 or comp-3, and at most 
"2" comp-1 on same node</p>
      * <p>comp-2 and comp-4 cannot be on same worker (missing comp-1 is 
implied from comp-1 constraint)</p>
      *
-     *  <p>
-     *      { "comp-1": { "maxNodeCoLocationCnt": 2, "incompatibleComponents": 
["comp-2", "comp-3" ] },
-     *        "comp-2": { "incompatibleComponents": [ "comp-4" ] }
-     *      }
-     *  </p>
+     * <p>
+     * { "comp-1": { "maxNodeCoLocationCnt": 2, "incompatibleComponents": 
["comp-2", "comp-3" ] },
+     * "comp-2": { "incompatibleComponents": [ "comp-4" ] }
+     * }
+     * </p>
      */
-    @IsExactlyOneOf(valueValidatorClasses = { 
ListOfListOfStringValidator.class, RasConstraintsTypeValidator.class })
+    @IsExactlyOneOf(valueValidatorClasses = 
{ListOfListOfStringValidator.class, RasConstraintsTypeValidator.class})
     public static final String TOPOLOGY_RAS_CONSTRAINTS = 
"topology.ras.constraints";
 
     /**
@@ -424,17 +424,17 @@ public class Config extends HashMap<String, Object> {
      *
      * <p>
      * 1. If not setting this variable or setting it as null,
-     *   a. If RAS is not used:
-     *        Nimbus will set it to {@link Config#TOPOLOGY_WORKERS}.
-     *   b. If RAS is used:
-     *        Nimbus will set it to (the estimate number of workers *  {@link 
Config#TOPOLOGY_RAS_ACKER_EXECUTORS_PER_WORKER}).
-     *        {@link Config#TOPOLOGY_RAS_ACKER_EXECUTORS_PER_WORKER} is 
default to be 1 if not set.
+     * a. If RAS is not used:
+     * Nimbus will set it to {@link Config#TOPOLOGY_WORKERS}.
+     * b. If RAS is used:
+     * Nimbus will set it to (the estimate number of workers *  {@link 
Config#TOPOLOGY_RAS_ACKER_EXECUTORS_PER_WORKER}).
+     * {@link Config#TOPOLOGY_RAS_ACKER_EXECUTORS_PER_WORKER} is default to be 
1 if not set.
      * 2. If this variable is set to 0,
-     *    then Storm will immediately ack tuples as soon as they come off the 
spout,
-     *    effectively disabling reliability.
+     * then Storm will immediately ack tuples as soon as they come off the 
spout,
+     * effectively disabling reliability.
      * 3. If this variable is set to a positive integer,
-     *    Storm will not honor {@link 
Config#TOPOLOGY_RAS_ACKER_EXECUTORS_PER_WORKER} setting.
-     *    Instead, nimbus will set it as (this variable / estimate num of 
workers).
+     * Storm will not honor {@link 
Config#TOPOLOGY_RAS_ACKER_EXECUTORS_PER_WORKER} setting.
+     * Instead, nimbus will set it as (this variable / estimate num of 
workers).
      * </p>
      */
     @IsInteger
@@ -465,7 +465,7 @@ public class Config extends HashMap<String, Object> {
      * <p>Note that EventLoggerBolt takes care of all the implementations of 
IEventLogger, hence registering many
      * implementations (especially they're implemented as 'blocking' manner) 
would slow down overall topology.
      */
-    @IsListEntryCustom(entryValidatorClasses = { 
EventLoggerRegistryValidator.class })
+    @IsListEntryCustom(entryValidatorClasses = 
{EventLoggerRegistryValidator.class})
     public static final String TOPOLOGY_EVENT_LOGGER_REGISTER = 
"topology.event.logger.register";
     /**
      * How many executors to spawn for event logger.
@@ -543,7 +543,7 @@ public class Config extends HashMap<String, Object> {
      * it's parallelism is configurable.
      */
 
-    @IsListEntryCustom(entryValidatorClasses = { MetricRegistryValidator.class 
})
+    @IsListEntryCustom(entryValidatorClasses = {MetricRegistryValidator.class})
     public static final String TOPOLOGY_METRICS_CONSUMER_REGISTER = 
"topology.metrics.consumer.register";
     /**
      * Enable tracking of network message byte counts per source-destination 
task. This is off by default as it creates tasks^2 metric
@@ -596,6 +596,20 @@ public class Config extends HashMap<String, Object> {
      */
     @IsPositiveNumber
     public static final String TOPOLOGY_STATS_SAMPLE_RATE = 
"topology.stats.sample.rate";
+    /**
+     * Enabling jitter streaming calculation (RFC 1889 §A.8).
+     *
+     * @see <a href="https://www.rfc-editor.org/rfc/rfc1889#appendix-A.8";>RFC 
1889 §A.8</a>
+     */
+    @IsBoolean
+    public static final String TOPOLOGY_STATS_EWMA_ENABLE = 
"topology.stats.ewma.enable";
+    /**
+     * The smoothing factor (alpha) used for exponential jitter calculation 
(RFC 1889 §A.8). The default value is set to 1/16.
+     *
+     * @see <a href="https://www.rfc-editor.org/rfc/rfc1889#appendix-A.8";>RFC 
1889 §A.8</a>
+     */
+    @CustomValidator(validatorClass = 
ConfigValidation.EwmaSmoothingFactorValidator.class)
+    public static final String TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR = 
"topology.stats.ewma.smoothing.factor";
     /**
      * The time period that builtin metrics data in bucketed into.
      */
@@ -833,14 +847,14 @@ public class Config extends HashMap<String, Object> {
      * Topology central logging sensitivity to determine who has access to 
logs in central logging system. The possible values are: S0 -
      * Public (open to all users on grid) S1 - Restricted S2 - Confidential S3 
- Secret (default.)
      */
-    @IsString(acceptedValues = { "S0", "S1", "S2", "S3" })
+    @IsString(acceptedValues = {"S0", "S1", "S2", "S3"})
     public static final String TOPOLOGY_LOGGING_SENSITIVITY = 
"topology.logging.sensitivity";
     /**
      * Log file the user can use to configure Log4j2.
      * Can be a resource in the jar (specified with 
classpath:/path/to/resource) or a file.
      * This configuration is applied in addition to the regular worker log4j2 
configuration.
      * The configs are merged according to the rules here:
-     *   
https://logging.apache.org/log4j/2.x/manual/configuration.html#CompositeConfiguration
+     * 
https://logging.apache.org/log4j/2.x/manual/configuration.html#CompositeConfiguration
      */
     @IsString
     public static final String TOPOLOGY_LOGGING_CONFIG_FILE = 
"topology.logging.config";
@@ -884,7 +898,8 @@ public class Config extends HashMap<String, Object> {
      * Alternatively set {@code storm.scheduler} to {@code 
org.apache.storm.scheduler.resource.ResourceAwareScheduler}
      * using {@link Config#TOPOLOGY_SCHEDULER_STRATEGY} set to
      * {@code 
org.apache.storm.scheduler.resource.strategies.scheduling.RoundRobinResourceAwareStrategy}
-     * */
+     *
+     */
     @IsInteger
     @IsPositiveNumber
     public static final String TOPOLOGY_ISOLATED_MACHINES = 
"topology.isolate.machines";
@@ -1434,22 +1449,34 @@ public class Config extends HashMap<String, Object> {
     @IsString
     public static final String STORM_ZOOKEEPER_TOPOLOGY_AUTH_SCHEME = 
"storm.zookeeper.topology.auth.scheme";
 
-    /** Enable SSL/TLS for ZooKeeper client connection. */
+    /**
+     * Enable SSL/TLS for ZooKeeper client connection.
+     */
     @IsBoolean
     public static final String ZK_SSL_ENABLE = "storm.zookeeper.ssl.enable";
-    /** Keystore location for ZooKeeper client connection over SSL. */
+    /**
+     * Keystore location for ZooKeeper client connection over SSL.
+     */
     @IsString
     public static final String STORM_ZOOKEEPER_SSL_KEYSTORE_PATH = 
"storm.zookeeper.ssl.keystore.path";
-    /** Keystore password for ZooKeeper client connection over SSL. */
+    /**
+     * Keystore password for ZooKeeper client connection over SSL.
+     */
     @IsString
     public static final String STORM_ZOOKEEPER_SSL_KEYSTORE_PASSWORD = 
"storm.zookeeper.ssl.keystore.password";
-    /** Truststore location for ZooKeeper client connection over SSL. */
+    /**
+     * Truststore location for ZooKeeper client connection over SSL.
+     */
     @IsString
     public static final String STORM_ZOOKEEPER_SSL_TRUSTSTORE_PATH = 
"storm.zookeeper.ssl.truststore.path";
-    /** Truststore password for ZooKeeper client connection over SSL.  */
+    /**
+     * Truststore password for ZooKeeper client connection over SSL.
+     */
     @IsString
     public static final String STORM_ZOOKEEPER_SSL_TRUSTSTORE_PASSWORD = 
"storm.zookeeper.ssl.truststore.password";
-    /** Enable or disable hostname verification.*/
+    /**
+     * Enable or disable hostname verification.
+     */
     @IsBoolean
     public static final String STORM_ZOOKEEPER_SSL_HOSTNAME_VERIFICATION = 
"storm.zookeeper.ssl.hostnameVerification";
     /**
@@ -1462,13 +1489,13 @@ public class Config extends HashMap<String, Object> {
     /**
      * Configure the topology metrics reporters to be used on workers.
      */
-    @IsListEntryCustom(entryValidatorClasses = { 
MetricReportersValidator.class })
+    @IsListEntryCustom(entryValidatorClasses = 
{MetricReportersValidator.class})
     public static final String TOPOLOGY_METRICS_REPORTERS = 
"topology.metrics.reporters";
 
     /**
      * A list of system metrics reporters that will get added to each topology.
      */
-    @IsListEntryCustom(entryValidatorClasses = { 
MetricReportersValidator.class })
+    @IsListEntryCustom(entryValidatorClasses = 
{MetricReportersValidator.class})
     public static final String STORM_TOPOLOGY_METRICS_SYSTEM_REPORTERS = 
"storm.topology.metrics.system.reporters";
 
     /**
@@ -1476,7 +1503,7 @@ public class Config extends HashMap<String, Object> {
      * Use {@link Config#TOPOLOGY_METRICS_REPORTERS} instead.
      */
     @Deprecated(forRemoval = true, since = "2.0.0")
-    @IsListEntryCustom(entryValidatorClasses = { 
MetricReportersValidator.class })
+    @IsListEntryCustom(entryValidatorClasses = 
{MetricReportersValidator.class})
     public static final String STORM_METRICS_REPORTERS = 
"storm.metrics.reporters";
 
     /**
@@ -1511,6 +1538,7 @@ public class Config extends HashMap<String, Object> {
     public static final String BLOBSTORE_HDFS_PRINCIPAL = 
"blobstore.hdfs.principal";
     /**
      * keytab for nimbus/supervisor to use to access secure hdfs for the 
blobstore.
+     *
      * @Deprecated Use {@link Config#STORM_HDFS_LOGIN_KEYTAB} instead.
      */
     @Deprecated
@@ -1753,7 +1781,7 @@ public class Config extends HashMap<String, Object> {
      */
     @IsInteger
     public static final String STORM_MESSAGING_NETTY_CLIENT_WORKER_THREADS =
-            "storm.messaging.netty.client_worker_threads";
+        "storm.messaging.netty.client_worker_threads";
 
     /**
      * Netty based messaging: Enables TLS connections between workers.
@@ -1808,7 +1836,7 @@ public class Config extends HashMap<String, Object> {
      */
     @IsString
     public static final String 
STORM_MESSAGING_NETTY_TLS_CLIENT_TRUSTSTORE_PASSWORD =
-            "storm.messaging.netty.tls.client.truststore.password";
+        "storm.messaging.netty.tls.client.truststore.password";
 
     /**
      * Netty based messaging: Specifies the client keystore when TLS is 
enabled.
@@ -1821,7 +1849,7 @@ public class Config extends HashMap<String, Object> {
      */
     @IsString
     public static final String 
STORM_MESSAGING_NETTY_TLS_CLIENT_KEYSTORE_PASSWORD =
-            "storm.messaging.netty.tls.client.keystore.password";
+        "storm.messaging.netty.tls.client.keystore.password";
 
     /**
      * Netty based messaging: Specifies the protocols TLS is enabled.
@@ -1830,7 +1858,7 @@ public class Config extends HashMap<String, Object> {
     public static final String STORM_MESSAGING_NETTY_TLS_SSL_PROTOCOLS = 
"storm.messaging.netty.tls.ssl.protocols";
 
     /**
-    /**
+     * /**
      * Netty based messaging: The number of milliseconds that a Netty client 
will retry flushing messages that are already
      * buffered to be sent.
      */
@@ -1915,7 +1943,7 @@ public class Config extends HashMap<String, Object> {
     @IsPositiveNumber
     @IsInteger
     public static final String 
STORM_BLOBSTORE_DEPENDENCY_JAR_UPLOAD_CHUNK_SIZE_BYTES =
-            "storm.blobstore.dependency.jar.upload.chunk.size.bytes";
+        "storm.blobstore.dependency.jar.upload.chunk.size.bytes";
     /**
      * FQCN of a class that implements {@code ISubmitterHook} @see 
ISubmitterHook for details.
      */
@@ -1924,8 +1952,8 @@ public class Config extends HashMap<String, Object> {
     /**
      * Impersonation user ACL config entries.
      */
-    @IsMapEntryCustom(keyValidatorClasses = { 
ConfigValidation.StringValidator.class },
-        valueValidatorClasses = { 
ConfigValidation.ImpersonationAclUserEntryValidator.class })
+    @IsMapEntryCustom(keyValidatorClasses = 
{ConfigValidation.StringValidator.class},
+        valueValidatorClasses = 
{ConfigValidation.ImpersonationAclUserEntryValidator.class})
     public static final String NIMBUS_IMPERSONATION_ACL = 
"nimbus.impersonation.acl";
     /**
      * A whitelist of the RAS scheduler strategies allowed by nimbus. Should 
be a list of fully-qualified class names or null to allow all.
@@ -2430,6 +2458,7 @@ public class Config extends HashMap<String, Object> {
 
     /**
      * Get the hostname substituted hdfs principal.
+     *
      * @param conf the storm Configuration
      * @return the principal
      * @throws UnknownHostException on UnknowHostException
@@ -2458,6 +2487,7 @@ public class Config extends HashMap<String, Object> {
 
     /**
      * Get the hdfs keytab.
+     *
      * @param conf the storm Configuration
      * @return the keytab
      */
diff --git a/storm-client/src/jvm/org/apache/storm/metrics2/EwmaGauge.java 
b/storm-client/src/jvm/org/apache/storm/metrics2/EwmaGauge.java
new file mode 100644
index 000000000..857e34215
--- /dev/null
+++ b/storm-client/src/jvm/org/apache/storm/metrics2/EwmaGauge.java
@@ -0,0 +1,84 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.  The 
ASF licenses this file to you under the Apache License, Version
+ * 2.0 (the "License"); you may not use this file except in compliance with 
the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package org.apache.storm.metrics2;
+
+import static org.apache.storm.utils.ConfigUtils.RFC1889_ALPHA;
+
+import com.codahale.metrics.Gauge;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Lock-free jitter estimator following RFC 1889 §A.8 / RFC 3550 §A.8.
+ * The jitter accumulator is stored as raw IEEE 754 bits in an AtomicLong
+ * so that CAS can be used without locks.
+ * Thread safety: addValue is lock-free; getValue is wait-free.
+ */
+public class EwmaGauge implements Gauge<Double> {
+
+    private static final long UNSEEDED = Long.MIN_VALUE;
+    private static final long ZERO_BITS = Double.doubleToLongBits(0.0);
+
+    private final AtomicLong lastTransit = new AtomicLong(UNSEEDED);
+    private final AtomicLong jitterBits = new AtomicLong(ZERO_BITS);
+    private final double alpha;
+
+    EwmaGauge(double alpha) {
+        if (alpha <= 0.0 || alpha >= 1.0 || Double.isNaN(alpha)) {
+            throw new IllegalArgumentException(
+                    "alpha must be in (0, 1), got: " + alpha);
+        }
+        this.alpha = alpha;
+    }
+
+    EwmaGauge() {
+        this(RFC1889_ALPHA);  // 1.0 / 16.0
+    }
+
+    /**
+     * Update the jitter estimate.
+     *
+     * @param transitMs transit time for this tuple: {@code arrival - 
timestamp}
+     *                  Negative values are silently ignored.
+     */
+    public void addValue(long transitMs) {
+        if (transitMs < 0) {
+            return;
+        }
+        // Seed on the very first packet: store transit, nothing to diff 
against yet.
+        if (lastTransit.compareAndSet(UNSEEDED, transitMs)) {
+            return;
+        }
+        long prev = lastTransit.getAndSet(transitMs);
+        // Safe from Math.abs(Long.MIN_VALUE) pathology: both transitMs and 
prev
+        // are >= 0 (enforced by the negative-guard), so their
+        // difference is in [-Long.MAX_VALUE, Long.MAX_VALUE].
+        double d = Math.abs(transitMs - prev);
+        long currentBits;
+        long updatedBits;
+        do {
+            currentBits = jitterBits.get();
+            double currentJitter = Double.longBitsToDouble(currentBits);
+            double updatedJitter = currentJitter + alpha * (d - currentJitter);
+            updatedBits = Double.doubleToLongBits(updatedJitter);
+        } while (!jitterBits.compareAndSet(currentBits, updatedBits));
+    }
+
+    /**
+     * Returns the current jitter estimate in timestamp units.
+     */
+    @Override
+    public Double getValue() {
+        return Double.longBitsToDouble(jitterBits.get());
+    }
+}
diff --git a/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java 
b/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java
index 0ac3a5e94..78cd6d3b9 100644
--- a/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java
+++ b/storm-client/src/jvm/org/apache/storm/metrics2/TaskMetrics.java
@@ -12,10 +12,12 @@
 
 package org.apache.storm.metrics2;
 
-import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.function.Supplier;
+
 import org.apache.storm.task.WorkerTopologyContext;
 import org.apache.storm.utils.ConfigUtils;
 import org.apache.storm.utils.Utils;
@@ -27,12 +29,18 @@ public class TaskMetrics {
     private static final String METRIC_NAME_TRANSFERRED = "__transfer-count";
     private static final String METRIC_NAME_EXECUTED = "__execute-count";
     private static final String METRIC_NAME_PROCESS_LATENCY = 
"__process-latency";
+    private static final String METRIC_NAME_PROCESS_JITTER = 
"__process-jitter";
     private static final String METRIC_NAME_COMPLETE_LATENCY = 
"__complete-latency";
+    private static final String METRIC_NAME_COMPLETE_JITTER = 
"__complete-jitter";
     private static final String METRIC_NAME_EXECUTE_LATENCY = 
"__execute-latency";
+    private static final String METRIC_NAME_EXECUTE_JITTER = 
"__execute-jitter";
     private static final String METRIC_NAME_CAPACITY = "__capacity";
 
     private final ConcurrentMap<String, RateCounter> rateCounters = new 
ConcurrentHashMap<>();
-    private final ConcurrentMap<String, RollingAverageGauge> gauges = new 
ConcurrentHashMap<>();
+    private final ConcurrentMap<String, Gauge<?>> gauges = new 
ConcurrentHashMap<>();
+    // Gauge supplier singleton factories
+    private final Supplier<EwmaGauge> ewmaGaugeFactory;
+    private final Supplier<RollingAverageGauge> rollingAverageGaugeFactory;
 
     private final String topologyId;
     private final String componentId;
@@ -40,6 +48,7 @@ public class TaskMetrics {
     private final Integer workerPort;
     private final StormMetricRegistry metricRegistry;
     private final int samplingRate;
+    private final boolean ewmaEnable;
 
 
     public TaskMetrics(WorkerTopologyContext context, String componentId, 
Integer taskid,
@@ -50,6 +59,10 @@ public class TaskMetrics {
         this.taskId = taskid;
         this.workerPort = context.getThisWorkerPort();
         this.samplingRate = ConfigUtils.samplingRate(topoConf);
+        double ewmaSmoothingFactor = ConfigUtils.ewmaSmoothingFactor(topoConf);
+        this.ewmaEnable = ConfigUtils.ewmaEnable(topoConf);
+        this.rollingAverageGaugeFactory = RollingAverageGauge::new;
+        this.ewmaGaugeFactory = () -> new EwmaGauge(ewmaSmoothingFactor);
     }
 
     public void setCapacity(double capacity) {
@@ -67,6 +80,12 @@ public class TaskMetrics {
         metricName = METRIC_NAME_COMPLETE_LATENCY + "-" + streamId;
         RollingAverageGauge gauge = this.getRollingAverageGauge(metricName, 
streamId);
         gauge.addValue(latencyMs);
+
+        if (this.ewmaEnable) {
+            metricName = METRIC_NAME_COMPLETE_JITTER + "-" + streamId;
+            EwmaGauge ewmaGauge = 
this.getExponentialWeightedMovingAverageGauge(metricName, streamId);
+            ewmaGauge.addValue(latencyMs);
+        }
     }
 
     public void boltAckedTuple(String sourceComponentId, String 
sourceStreamId, long latencyMs) {
@@ -78,6 +97,12 @@ public class TaskMetrics {
         metricName = METRIC_NAME_PROCESS_LATENCY + "-" + key;
         RollingAverageGauge gauge = this.getRollingAverageGauge(metricName, 
sourceStreamId);
         gauge.addValue(latencyMs);
+
+        if (this.ewmaEnable) {
+            metricName = METRIC_NAME_PROCESS_JITTER + "-" + key;
+            EwmaGauge ewmaGauge = 
this.getExponentialWeightedMovingAverageGauge(metricName, sourceStreamId);
+            ewmaGauge.addValue(latencyMs);
+        }
     }
 
     public void spoutFailedTuple(String streamId) {
@@ -117,6 +142,12 @@ public class TaskMetrics {
         metricName = METRIC_NAME_EXECUTE_LATENCY + "-" + key;
         RollingAverageGauge gauge = this.getRollingAverageGauge(metricName, 
sourceStreamId);
         gauge.addValue(latencyMs);
+
+        if (this.ewmaEnable) {
+            metricName = METRIC_NAME_EXECUTE_JITTER + "-" + key;
+            EwmaGauge ewmaGauge = 
this.getExponentialWeightedMovingAverageGauge(metricName, sourceStreamId);
+            ewmaGauge.addValue(latencyMs);
+        }
     }
 
     private RateCounter getRateCounter(String metricName, String streamId) {
@@ -135,18 +166,54 @@ public class TaskMetrics {
     }
 
     private RollingAverageGauge getRollingAverageGauge(String metricName, 
String streamId) {
-        RollingAverageGauge gauge = this.gauges.get(metricName);
-        if (gauge == null) {
+        return getOrCreateGauge(metricName, streamId, 
RollingAverageGauge.class, this.rollingAverageGaugeFactory);
+    }
+
+    private EwmaGauge getExponentialWeightedMovingAverageGauge(String 
metricName, String streamId) {
+        return getOrCreateGauge(metricName, streamId, EwmaGauge.class, 
this.ewmaGaugeFactory);
+    }
+
+    private <G extends Gauge<?>> G getOrCreateGauge(
+            String metricName,
+            String streamId,
+            Class<G> gaugeClass,
+            Supplier<G> factory) {
+
+        Object existing = this.gauges.get(metricName);
+        if (existing == null) {
             synchronized (this) {
-                gauge = this.gauges.get(metricName);
-                if (gauge == null) {
-                    gauge = new RollingAverageGauge();
-                    metricRegistry.gauge(metricName, gauge, this.topologyId, 
this.componentId,
-                            streamId, this.taskId, this.workerPort);
-                    this.gauges.put(metricName, gauge);
+                existing = this.gauges.get(metricName);
+                if (existing == null) {
+                    G created = factory.get();
+                    registerGauge(metricName, streamId, created);
+                    this.gauges.put(metricName, created);
+                    return created;
                 }
             }
         }
-        return gauge;
+
+        if (!gaugeClass.isInstance(existing)) {
+            throw new IllegalStateException(
+                    "Metric '" + metricName + "' is registered as "
+                            + existing.getClass().getName()
+                            + " but expected " + gaugeClass.getName());
+        }
+
+        return gaugeClass.cast(existing);
+    }
+
+    /*
+     * Safe cast: G is bounded by Gauge<?> in the signature of 
getOrCreateGauge,
+     * so every instance of G is by definition a Gauge.
+     * The cast to raw Gauge is required because metricRegistry.gauge() does 
not
+     * accept Gauge<?> the wildcard is not compatible with the type parameter T
+     * expected by the external API. Type-safety is guaranteed by the bound
+     * <G extends Gauge<?>> declared at the call site.
+     */
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private void registerGauge(String metricName, String streamId, Gauge<?> 
gauge) {
+        metricRegistry.gauge(metricName, (Gauge) gauge, this.topologyId,
+                this.componentId, streamId, this.taskId, this.workerPort);
     }
+
 }
diff --git a/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java 
b/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
index 9357cc562..94599ec89 100644
--- a/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
+++ b/storm-client/src/jvm/org/apache/storm/utils/ConfigUtils.java
@@ -39,6 +39,7 @@ public class ConfigUtils {
     public static final String FILE_SEPARATOR = File.separator;
     public static final String STORM_HOME = "storm.home";
     public static final String RESOURCES_SUBDIR = "resources";
+    public static final double RFC1889_ALPHA = 1.0 / 16.0;
 
     private static final Set<String> passwordConfigKeys = new HashSet<>();
 
@@ -175,6 +176,28 @@ public class ConfigUtils {
         throw new IllegalArgumentException("Illegal topology.stats.sample.rate 
in conf: " + rate);
     }
 
+    public static double ewmaSmoothingFactor(Map<String, Object> conf) {
+        Object value = conf.get(Config.TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR);
+        if (value == null) {
+            return RFC1889_ALPHA;
+        }
+        double alpha = ObjectReader.getDouble(value);
+        if (alpha > 0.0 && alpha < 1.0) {
+            return alpha;
+        }
+        throw new IllegalArgumentException(
+                "Illegal " + Config.TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR
+                        + " in conf: " + alpha + " must be in (0, 1)");
+    }
+
+    public static boolean ewmaEnable(Map<String, Object> conf) {
+        Object value = conf.get(Config.TOPOLOGY_STATS_EWMA_ENABLE);
+        if (value == null) {
+            return false;
+        }
+        return ObjectReader.getBoolean(value, false);
+    }
+
     public static BooleanSupplier mkStatsSampler(Map<String, Object> conf) {
         return evenSampler(samplingRate(conf));
     }
diff --git a/storm-client/src/jvm/org/apache/storm/utils/ObjectReader.java 
b/storm-client/src/jvm/org/apache/storm/utils/ObjectReader.java
index 54445b5eb..ac28be8cf 100644
--- a/storm-client/src/jvm/org/apache/storm/utils/ObjectReader.java
+++ b/storm-client/src/jvm/org/apache/storm/utils/ObjectReader.java
@@ -128,6 +128,8 @@ public class ObjectReader {
         }
         if (o instanceof Number) {
             return ((Number) o).doubleValue();
+        } else if (o instanceof String) {
+            return Double.parseDouble((String) o);
         } else {
             throw new IllegalArgumentException("Don't know how to convert (" + 
o + ") to double");
         }
diff --git 
a/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java 
b/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java
index 4175ee9f4..0206ec458 100644
--- a/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java
+++ b/storm-client/src/jvm/org/apache/storm/validation/ConfigValidation.java
@@ -30,6 +30,7 @@ import java.util.Set;
 import java.util.stream.Collectors;
 
 import org.apache.storm.Config;
+import org.apache.storm.utils.ObjectReader;
 import org.apache.storm.utils.Utils;
 import org.apache.storm.validation.ConfigValidationAnnotations.ValidatorParams;
 import org.slf4j.Logger;
@@ -849,6 +850,23 @@ public class ConfigValidation {
         }
     }
 
+    public static class EwmaSmoothingFactorValidator extends Validator {
+        @Override
+        public void validateField(String name, Object o) {
+            if (o == null) {
+                return;
+            }
+            // ObjectReader.getDouble(o) handles the type conversion and will 
throw an
+            // IllegalArgumentException if the value cannot be parsed as a 
number.
+            double alpha = ObjectReader.getDouble(o);
+            if (alpha > 0.0 && alpha < 1.0) {
+                return;
+            }
+            throw new IllegalArgumentException(
+                    "Field " + name + " must be a number in the open interval 
(0, 1), got: " + o);
+        }
+    }
+
     public static class CustomIsExactlyOneOfValidators extends Validator {
         private Class<?>[] subValidators;
         private List<String> validatorClassNames;
diff --git a/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java 
b/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java
index 3c335308d..31b9c4834 100644
--- a/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java
+++ b/storm-client/test/jvm/org/apache/storm/TestConfigValidate.java
@@ -206,6 +206,34 @@ public class TestConfigValidate {
         ConfigValidation.validateFields(conf);
     }
 
+    @Test
+    public void testTopologyStatsEwmaEnableIsBoolean() {
+        Map<String, Object> conf = new HashMap<>();
+        // optional configuration
+        ConfigValidation.validateFields(conf);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_ENABLE, true);
+        ConfigValidation.validateFields(conf);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_ENABLE, false);
+        ConfigValidation.validateFields(conf);
+    }
+
+    @Test
+    public void testTopologyStatsEwmaSmoothingFactorCustomValidator() {
+        Map<String, Object> conf = new HashMap<>();
+        // optional configuration
+        ConfigValidation.validateFields(conf);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR, 0.1);
+        ConfigValidation.validateFields(conf);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR, "0.1");
+        ConfigValidation.validateFields(conf);
+        conf.put(Config.TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR, 0.9);
+        ConfigValidation.validateFields(conf);
+        for (Object notAllowedValue : new Object[]{0.0, -0.1, 1.9, "1.9"}) {
+            conf.put(Config.TOPOLOGY_STATS_EWMA_SMOOTHING_FACTOR, 
notAllowedValue);
+            assertThrows(IllegalArgumentException.class, () -> 
ConfigValidation.validateFields(conf));
+        }
+    }
+
     @Test
     public void testWorkerChildoptsIsStringOrStringList() {
         Map<String, Object> conf = new HashMap<>();
diff --git a/storm-client/test/jvm/org/apache/storm/metrics2/EwmaGaugeTest.java 
b/storm-client/test/jvm/org/apache/storm/metrics2/EwmaGaugeTest.java
new file mode 100644
index 000000000..ed1b5009c
--- /dev/null
+++ b/storm-client/test/jvm/org/apache/storm/metrics2/EwmaGaugeTest.java
@@ -0,0 +1,347 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.  The 
ASF licenses this file to you under the Apache License, Version
+ * 2.0 (the "License"); you may not use this file except in compliance with 
the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package org.apache.storm.metrics2;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class EwmaGaugeTest {
+
+    private static final double DELTA = 1e-9;
+
+    @Nested
+    @DisplayName("Construction")
+    class ConstructionTest {
+
+        @Test
+        @DisplayName("Default constructor uses RFC 1889 alpha (1/16)")
+        void defaultAlpha() {
+            EwmaGauge gauge = new EwmaGauge();
+            gauge.addValue(0L);
+            gauge.addValue(16L); // D = 16 ; J = 0 + (16 - 0) * (1/16) = 1.0
+            assertEquals(1.0, gauge.getValue(), DELTA);
+        }
+
+        @Test
+        @DisplayName("Invalid alpha values throw IllegalArgumentException")
+        void invalidAlphaThrows() {
+            double[] invalidAlphas = {
+                    0.0, 1.0, -0.1, 1.1,
+                    Double.NaN, Double.POSITIVE_INFINITY, 
Double.NEGATIVE_INFINITY
+            };
+            for (double alpha : invalidAlphas) {
+                assertThrows(IllegalArgumentException.class,
+                        () -> new EwmaGauge(alpha),
+                        "Expected IllegalArgumentException for alpha=" + 
alpha);
+            }
+        }
+
+        @Test
+        @DisplayName("Valid alpha boundary values are accepted")
+        void validAlphaAccepted() {
+            double[] validAlphas = {0.001, 0.0625, 0.5, 0.999};
+            for (double alpha : validAlphas) {
+                assertNotNull(new EwmaGauge(alpha),
+                        "Expected no exception for alpha=" + alpha);
+            }
+        }
+    }
+
+
+    @Nested
+    @DisplayName("Cold-start semantics")
+    class ColdStartTest {
+
+        private EwmaGauge gauge;
+
+        @BeforeEach
+        void setUp() {
+            gauge = new EwmaGauge();
+        }
+
+        @Test
+        @DisplayName("getValue() returns 0.0 before any sample")
+        void noSamples() {
+            assertEquals(0.0, gauge.getValue(), DELTA);
+        }
+
+        @Test
+        @DisplayName("getValue() returns 0.0 after exactly one sample (seed 
only)")
+        void oneSample() {
+            gauge.addValue(100L);
+            assertEquals(0.0, gauge.getValue(), DELTA);
+        }
+
+    }
+
+    @Nested
+    @DisplayName("EWMA formula RFC 1889 §A.8")
+    class FormulaTest {
+
+        @Test
+        @DisplayName("Single update: J = 0 + (D - 0) * alpha")
+        void singleDeviation() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+            gauge.addValue(0L);
+            gauge.addValue(10L);
+            assertEquals(5.0, gauge.getValue(), DELTA);
+        }
+
+        @Test
+        @DisplayName("Manual step-by-step verification against reference 
values")
+        void manualSteps() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+
+            gauge.addValue(0L); // seed
+
+            // Step 1: transit=10, prev=0,  D=10, J = 0    + (10-0)    * 0.5 = 
5.0
+            gauge.addValue(10L);
+            assertEquals(5.0, gauge.getValue(), DELTA, "Step 1");
+
+            // Step 2: transit=0,  prev=10, D=10, J = 5.0  + (10-5.0)  * 0.5 = 
7.5
+            gauge.addValue(0L);
+            assertEquals(7.5, gauge.getValue(), DELTA, "Step 2");
+
+            // Step 3: transit=10, prev=0,  D=10, J = 7.5  + (10-7.5)  * 0.5 = 
8.75
+            gauge.addValue(10L);
+            assertEquals(8.75, gauge.getValue(), DELTA, "Step 3");
+        }
+
+        @Test
+        @DisplayName("Zero deviation decays jitter toward zero")
+        void zeroDeviationDecays() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+            gauge.addValue(0L); // 0
+            gauge.addValue(10L); // 0 + 5*alpha = 2.5
+            double afterFirst = gauge.getValue();
+            assertEquals(afterFirst, gauge.getValue(), DELTA);
+
+            gauge.addValue(10L); // 2.5 - 2.5*alpha = 2.5 - 1.25 = 1.25
+            assertEquals(afterFirst * 0.5, gauge.getValue(), DELTA);
+        }
+
+    }
+
+
+    @Nested
+    @DisplayName("Negative value guard")
+    class NegativeValueTest {
+
+        @Test
+        @DisplayName("Negative transit values are silently ignored before 
seed")
+        void negativeIgnoredBeforeSeed() {
+            EwmaGauge gauge = new EwmaGauge();
+            gauge.addValue(-1L);
+            gauge.addValue(-100L);
+            assertEquals(0.0, gauge.getValue(), DELTA);
+        }
+
+        @Test
+        @DisplayName("Negative value after seed does not corrupt lastTransit")
+        void negativeAfterSeedIgnored() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+            gauge.addValue(10L);
+            gauge.addValue(-5L);
+            gauge.addValue(20L);
+            assertEquals(5.0, gauge.getValue(), DELTA);
+        }
+    }
+
+
+    @Nested
+    @DisplayName("getValue() preserves EWMA across calls")
+    class GetValueIdempotentTest {
+
+        @Test
+        @DisplayName("Repeated getValue() without new samples returns same 
estimate")
+        void repeatedGetValueStable() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+            gauge.addValue(0L);
+            gauge.addValue(10L);
+            double first = gauge.getValue();
+
+            assertEquals(first, gauge.getValue(), DELTA, "Second call");
+            assertEquals(first, gauge.getValue(), DELTA, "Third call");
+        }
+
+        @Test
+        @DisplayName("EWMA accumulates correctly across multiple reporting 
windows")
+        void acrossReportingWindows() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+            gauge.addValue(0L);
+
+            gauge.addValue(10L);
+            assertEquals(5.0, gauge.getValue(), DELTA, "Window 1");
+
+            gauge.addValue(0L);
+            assertEquals(7.5, gauge.getValue(), DELTA, "Window 2");
+
+            gauge.addValue(10L);
+            assertEquals(8.75, gauge.getValue(), DELTA, "Window 3");
+        }
+    }
+
+    @Nested
+    @DisplayName("thread safe")
+    class ConcurrencyTest {
+
+        @Test
+        @DisplayName("Concurrent addValue() calls do not corrupt state")
+        void concurrentAddValue() throws InterruptedException {
+            EwmaGauge gauge = new EwmaGauge();
+            int threads = 8;
+            int samplesPerThread = 10_000;
+            CountDownLatch ready = new CountDownLatch(threads);
+            CountDownLatch start = new CountDownLatch(1);
+            ExecutorService pool = Executors.newFixedThreadPool(threads);
+
+            for (int t = 0; t < threads; t++) {
+                final long base = t * 10L;
+                pool.submit(() -> {
+                    ready.countDown();
+                    try {
+                        start.await();
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    for (int i = 0; i < samplesPerThread; i++) {
+                        gauge.addValue(base + (i % 10));
+                    }
+                });
+            }
+
+            ready.await();
+            start.countDown();
+            pool.shutdown();
+            assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS),
+                    "Executor did not terminate — possible deadlock");
+
+            double value = gauge.getValue();
+            assertTrue(value >= 0.0, "Jitter must be non-negative, got: " + 
value);
+            assertTrue(Double.isFinite(value), "Jitter must be finite, got: " 
+ value);
+        }
+
+        @Test
+        @DisplayName("Concurrent getValue() and addValue() do not deadlock")
+        void concurrentGetAndAdd() throws Exception {
+            EwmaGauge gauge = new EwmaGauge();
+            ExecutorService pool = Executors.newFixedThreadPool(2);
+            CountDownLatch done = new CountDownLatch(2);
+
+            Future<?> writer = pool.submit(() -> {
+                for (int i = 0; i < 50_000; i++) {
+                    gauge.addValue(i % 100);
+                }
+                done.countDown();
+            });
+
+            Future<?> reader = pool.submit(() -> {
+                for (int i = 0; i < 1_000; i++) {
+                    double v = gauge.getValue();
+                    assertTrue(v >= 0.0 && Double.isFinite(v),
+                            "getValue() returned invalid result: " + v);
+                }
+                done.countDown();
+            });
+
+            assertTrue(done.await(10, TimeUnit.SECONDS),
+                    "Test did not complete within timeout possible deadlock");
+
+            writer.get();
+            reader.get();
+            pool.shutdown();
+        }
+
+        @Test
+        @DisplayName("Only one thread seeds lastTransit all same value gives 
zero jitter")
+        void seedRace() throws InterruptedException {
+            EwmaGauge gauge = new EwmaGauge();
+            int threads = 16;
+            CountDownLatch ready = new CountDownLatch(threads);
+            CountDownLatch start = new CountDownLatch(1);
+            ExecutorService pool = Executors.newFixedThreadPool(threads);
+
+            for (int t = 0; t < threads; t++) {
+                pool.submit(() -> {
+                    ready.countDown();
+                    try {
+                        start.await();
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    gauge.addValue(42L);
+                });
+            }
+
+            ready.await();
+            start.countDown();
+            pool.shutdown();
+            assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS),
+                    "Executor did not terminate possible deadlock");
+
+            assertEquals(0.0, gauge.getValue(), DELTA);
+        }
+    }
+
+    @Nested
+    @DisplayName("Edge cases")
+    class EdgeCaseTest {
+
+        @Test
+        @DisplayName("Long.MAX_VALUE transit does not overflow deviation")
+        void maxLongTransit() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+            gauge.addValue(0L);
+            gauge.addValue(Long.MAX_VALUE);
+            double value = gauge.getValue();
+            assertTrue(value > 0.0, "Jitter should be positive");
+            assertTrue(Double.isFinite(value), "Jitter should be finite");
+        }
+
+        @Test
+        @DisplayName("Zero transit time is valid and produces zero deviation")
+        void zeroTransit() {
+            EwmaGauge gauge = new EwmaGauge(0.5);
+            gauge.addValue(0L);
+            gauge.addValue(0L);
+            assertEquals(0.0, gauge.getValue(), DELTA);
+        }
+
+        @Test
+        @DisplayName("Large number of samples does not overflow LongAdder")
+        void manySamples() {
+            EwmaGauge gauge = new EwmaGauge();
+            gauge.addValue(0L);
+            for (int i = 1; i <= 100_000; i++) {
+                gauge.addValue(i % 2 == 0 ? 0L : 10L);
+            }
+            double value = gauge.getValue();
+            assertTrue(value > 0.0, "Jitter should be positive after many 
samples");
+            assertTrue(value <= 10.0, "Jitter cannot exceed max deviation of 
10");
+            assertTrue(Double.isFinite(value), "Jitter must be finite");
+        }
+    }
+}
diff --git 
a/storm-client/test/jvm/org/apache/storm/metrics2/TaskMetricsTest.java 
b/storm-client/test/jvm/org/apache/storm/metrics2/TaskMetricsTest.java
new file mode 100644
index 000000000..c81299428
--- /dev/null
+++ b/storm-client/test/jvm/org/apache/storm/metrics2/TaskMetricsTest.java
@@ -0,0 +1,485 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.  The 
ASF licenses this file to you under the Apache License, Version
+ * 2.0 (the "License"); you may not use this file except in compliance with 
the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package org.apache.storm.metrics2;
+
+import com.codahale.metrics.Gauge;
+import org.apache.storm.task.WorkerTopologyContext;
+import org.apache.storm.utils.ConfigUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class TaskMetricsTest {
+
+    private static final String TOPOLOGY_ID   = "test-topology-1";
+    private static final String COMPONENT_ID  = "test-bolt";
+    private static final Integer TASK_ID      = 42;
+    private static final Integer WORKER_PORT  = 6700;
+    private static final String STREAM_ID     = "default";
+    private static final String SOURCE_COMP   = "source-spout";
+    private static final int    SAMPLING_RATE = 1;
+    private static final double EWMA_FACTOR   = 0.3;
+
+    @Mock private WorkerTopologyContext context;
+    @Mock private StormMetricRegistry   metricRegistry;
+    @Mock private RateCounter           rateCounter;
+
+    private Map<String, Object> topoConf;
+
+    private TaskMetrics buildTaskMetrics(boolean ewmaEnabled) {
+        try (MockedStatic<ConfigUtils> cfgUtils = 
mockStatic(ConfigUtils.class)) {
+            cfgUtils.when(() -> 
ConfigUtils.samplingRate(topoConf)).thenReturn(SAMPLING_RATE);
+            cfgUtils.when(() -> 
ConfigUtils.ewmaSmoothingFactor(topoConf)).thenReturn(EWMA_FACTOR);
+            cfgUtils.when(() -> 
ConfigUtils.ewmaEnable(topoConf)).thenReturn(ewmaEnabled);
+
+            return new TaskMetrics(context, COMPONENT_ID, TASK_ID, 
metricRegistry, topoConf);
+        }
+    }
+
+    @BeforeEach
+    void setUp() {
+        when(context.getStormId()).thenReturn(TOPOLOGY_ID);
+        when(context.getThisWorkerPort()).thenReturn(WORKER_PORT);
+
+        topoConf = new HashMap<>();
+
+        when(metricRegistry.rateCounter(anyString(), anyString(), anyString(),
+                anyInt(), anyInt(), anyString())).thenReturn(rateCounter);
+    }
+
+    @Test
+    void spoutAckedTuple_incrementsAckCounter() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.spoutAckedTuple(STREAM_ID, 100L);
+
+        verify(rateCounter).inc(SAMPLING_RATE);
+    }
+
+    @Test
+    void spoutAckedTuple_registersCompleteLatencyGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.spoutAckedTuple(STREAM_ID, 200L);
+
+        verify(metricRegistry, atLeastOnce()).gauge(
+                contains("__complete-latency"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void spoutAckedTuple_withEwmaEnabled_registersJitterGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(true);
+
+        tm.spoutAckedTuple(STREAM_ID, 150L);
+
+        verify(metricRegistry, atLeastOnce()).gauge(
+                contains("__complete-jitter"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void spoutAckedTuple_withEwmaDisabled_doesNotRegisterJitterGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.spoutAckedTuple(STREAM_ID, 150L);
+
+        verify(metricRegistry, never()).gauge(
+                contains("__complete-rfc1889a-jitter"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void boltAckedTuple_incrementsAckCounter() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltAckedTuple(SOURCE_COMP, STREAM_ID, 50L);
+
+        verify(rateCounter).inc(SAMPLING_RATE);
+    }
+
+    @Test
+    void boltAckedTuple_registersProcessLatencyGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltAckedTuple(SOURCE_COMP, STREAM_ID, 50L);
+
+        verify(metricRegistry, atLeastOnce()).gauge(
+                contains("__process-latency"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void boltAckedTuple_withEwmaEnabled_registersJitterGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(true);
+
+        tm.boltAckedTuple(SOURCE_COMP, STREAM_ID, 75L);
+
+        verify(metricRegistry, atLeastOnce()).gauge(
+                contains("__process-jitter"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void boltAckedTuple_metricKeyIncludesSourceComponentAndStream() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltAckedTuple(SOURCE_COMP, STREAM_ID, 50L);
+
+        verify(metricRegistry).rateCounter(
+                contains(SOURCE_COMP + ":" + STREAM_ID),
+                anyString(), anyString(), anyInt(), anyInt(), anyString());
+    }
+
+    @Test
+    void spoutFailedTuple_incrementsFailCounter() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.spoutFailedTuple(STREAM_ID);
+
+        verify(rateCounter).inc(SAMPLING_RATE);
+    }
+
+    @Test
+    void spoutFailedTuple_usesCorrectMetricName() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.spoutFailedTuple(STREAM_ID);
+
+        verify(metricRegistry).rateCounter(
+                eq("__fail-count-" + STREAM_ID),
+                eq(TOPOLOGY_ID), eq(COMPONENT_ID), eq(TASK_ID), 
eq(WORKER_PORT), eq(STREAM_ID));
+    }
+
+    @Test
+    void boltFailedTuple_incrementsFailCounter() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltFailedTuple(SOURCE_COMP, STREAM_ID);
+
+        verify(rateCounter).inc(SAMPLING_RATE);
+    }
+
+    @Test
+    void boltFailedTuple_metricKeyIncludesSourceComponentAndStream() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltFailedTuple(SOURCE_COMP, STREAM_ID);
+
+        verify(metricRegistry).rateCounter(
+                eq("__fail-count-" + SOURCE_COMP + ":" + STREAM_ID),
+                anyString(), anyString(), anyInt(), anyInt(), anyString());
+    }
+
+    @Test
+    void emittedTuple_incrementsEmitCounter() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.emittedTuple(STREAM_ID);
+
+        verify(rateCounter).inc(SAMPLING_RATE);
+    }
+
+    @Test
+    void emittedTuple_usesCorrectMetricName() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.emittedTuple(STREAM_ID);
+
+        verify(metricRegistry).rateCounter(
+                eq("__emit-count-" + STREAM_ID),
+                eq(TOPOLOGY_ID), eq(COMPONENT_ID), eq(TASK_ID), 
eq(WORKER_PORT), eq(STREAM_ID));
+    }
+
+    @Test
+    void transferredTuples_incrementsByAmountTimesSamplingRate() {
+        TaskMetrics tm = buildTaskMetrics(false);
+        int amount = 5;
+
+        tm.transferredTuples(STREAM_ID, amount);
+
+        verify(rateCounter).inc(amount * SAMPLING_RATE);
+    }
+
+    @Test
+    void transferredTuples_usesCorrectMetricName() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.transferredTuples(STREAM_ID, 3);
+
+        verify(metricRegistry).rateCounter(
+                eq("__transfer-count-" + STREAM_ID),
+                anyString(), anyString(), anyInt(), anyInt(), anyString());
+    }
+
+    @Test
+    void boltExecuteTuple_incrementsExecuteCounter() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltExecuteTuple(SOURCE_COMP, STREAM_ID, 30L);
+
+        verify(rateCounter).inc(SAMPLING_RATE);
+    }
+
+    @Test
+    void boltExecuteTuple_registersExecuteLatencyGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltExecuteTuple(SOURCE_COMP, STREAM_ID, 30L);
+
+        verify(metricRegistry, atLeastOnce()).gauge(
+                contains("__execute-latency"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void boltExecuteTuple_withEwmaEnabled_registersJitterGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(true);
+
+        tm.boltExecuteTuple(SOURCE_COMP, STREAM_ID, 30L);
+
+        verify(metricRegistry, atLeastOnce()).gauge(
+                contains("__execute-jitter"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void boltExecuteTuple_withEwmaDisabled_doesNotRegisterJitterGauge() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.boltExecuteTuple(SOURCE_COMP, STREAM_ID, 30L);
+
+        verify(metricRegistry, never()).gauge(
+                contains("__execute-rfc1889a-jitter"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void differentStreams_produceSeparateRateCounters() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.emittedTuple("stream-A");
+        tm.emittedTuple("stream-B");
+
+        verify(metricRegistry).rateCounter(
+                eq("__emit-count-stream-A"),
+                anyString(), anyString(), anyInt(), anyInt(), eq("stream-A"));
+        verify(metricRegistry).rateCounter(
+                eq("__emit-count-stream-B"),
+                anyString(), anyString(), anyInt(), anyInt(), eq("stream-B"));
+    }
+
+    @Test
+    void rateCounter_registeredOnlyOnceForSameMetricName() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.emittedTuple(STREAM_ID);
+        tm.emittedTuple(STREAM_ID);
+        tm.emittedTuple(STREAM_ID);
+
+        verify(metricRegistry, times(1)).rateCounter(
+                eq("__emit-count-" + STREAM_ID),
+                anyString(), anyString(), anyInt(), anyInt(), anyString());
+    }
+
+    @Test
+    void gauge_registeredOnlyOnceForSameMetricName() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.spoutAckedTuple(STREAM_ID, 10L);
+        tm.spoutAckedTuple(STREAM_ID, 20L);
+        tm.spoutAckedTuple(STREAM_ID, 30L);
+
+        verify(metricRegistry, times(1)).gauge(
+                contains("__complete-latency"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void concurrentEmittedTuple_registersRateCounterExactlyOnce() throws 
InterruptedException {
+        TaskMetrics tm = buildTaskMetrics(false);
+        int threadCount = 20;
+        CountDownLatch ready = new CountDownLatch(threadCount);
+        CountDownLatch start = new CountDownLatch(1);
+        CountDownLatch done  = new CountDownLatch(threadCount);
+
+        ExecutorService pool = Executors.newFixedThreadPool(threadCount);
+        for (int i = 0; i < threadCount; i++) {
+            pool.submit(() -> {
+                ready.countDown();
+                try { start.await(); } catch (InterruptedException ignored) {}
+                tm.emittedTuple(STREAM_ID);
+                done.countDown();
+            });
+        }
+
+        ready.await();
+        start.countDown();
+        assertTrue(done.await(5, TimeUnit.SECONDS));
+        pool.shutdown();
+
+        verify(metricRegistry, times(1)).rateCounter(
+                eq("__emit-count-" + STREAM_ID),
+                anyString(), anyString(), anyInt(), anyInt(), anyString());
+    }
+
+    @Test
+    void concurrentSpoutAckedTuple_registersGaugeExactlyOnce() throws 
InterruptedException {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+        int threadCount = 20;
+        CountDownLatch ready = new CountDownLatch(threadCount);
+        CountDownLatch start = new CountDownLatch(1);
+        CountDownLatch done  = new CountDownLatch(threadCount);
+
+        ExecutorService pool = Executors.newFixedThreadPool(threadCount);
+        for (int i = 0; i < threadCount; i++) {
+            pool.submit(() -> {
+                ready.countDown();
+                try { start.await(); } catch (InterruptedException ignored) {}
+                tm.spoutAckedTuple(STREAM_ID, 100L);
+                done.countDown();
+            });
+        }
+
+        ready.await();
+        start.countDown();
+        assertTrue(done.await(5, TimeUnit.SECONDS));
+        pool.shutdown();
+
+        verify(metricRegistry, times(1)).gauge(
+                contains("__complete-latency"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void getOrCreateGauge_sameTypeReusedWithoutThrowing() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.spoutAckedTuple(STREAM_ID, 10L);
+        tm.spoutAckedTuple(STREAM_ID, 20L);
+
+        verify(metricRegistry, times(1)).gauge(
+                contains("__complete-latency"), any(Gauge.class),
+                anyString(), anyString(), anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void boltAckedTuple_metricNameFormat() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+        String expectedKey = SOURCE_COMP + ":" + STREAM_ID;
+
+        tm.boltAckedTuple(SOURCE_COMP, STREAM_ID, 10L);
+
+        verify(metricRegistry).rateCounter(
+                eq("__ack-count-" + expectedKey),
+                eq(TOPOLOGY_ID), eq(COMPONENT_ID), eq(TASK_ID), 
eq(WORKER_PORT), eq(STREAM_ID));
+    }
+
+    @Test
+    void boltExecuteTuple_metricNameFormat() {
+        when(metricRegistry.gauge(anyString(), any(Gauge.class), anyString(),
+                anyString(), anyString(), anyInt(), 
anyInt())).thenReturn(null);
+        TaskMetrics tm = buildTaskMetrics(false);
+        String expectedKey = SOURCE_COMP + ":" + STREAM_ID;
+
+        tm.boltExecuteTuple(SOURCE_COMP, STREAM_ID, 10L);
+
+        verify(metricRegistry).rateCounter(
+                eq("__execute-count-" + expectedKey),
+                eq(TOPOLOGY_ID), eq(COMPONENT_ID), eq(TASK_ID), 
eq(WORKER_PORT), eq(STREAM_ID));
+    }
+
+    @Test
+    void boltFailedTuple_metricNameFormat() {
+        TaskMetrics tm = buildTaskMetrics(false);
+        String expectedKey = SOURCE_COMP + ":" + STREAM_ID;
+
+        tm.boltFailedTuple(SOURCE_COMP, STREAM_ID);
+
+        verify(metricRegistry).rateCounter(
+                eq("__fail-count-" + expectedKey),
+                eq(TOPOLOGY_ID), eq(COMPONENT_ID), eq(TASK_ID), 
eq(WORKER_PORT), eq(STREAM_ID));
+    }
+
+    @Test
+    void contextFields_propagatedCorrectlyToRegistry() {
+        TaskMetrics tm = buildTaskMetrics(false);
+
+        tm.emittedTuple(STREAM_ID);
+
+        ArgumentCaptor<String> topoCaptor  = 
ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> compCaptor  = 
ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<Integer> taskCaptor = 
ArgumentCaptor.forClass(Integer.class);
+        ArgumentCaptor<Integer> portCaptor = 
ArgumentCaptor.forClass(Integer.class);
+
+        verify(metricRegistry).rateCounter(
+                anyString(),
+                topoCaptor.capture(), compCaptor.capture(),
+                taskCaptor.capture(), portCaptor.capture(),
+                anyString());
+
+        assertEquals(TOPOLOGY_ID, topoCaptor.getValue());
+        assertEquals(COMPONENT_ID, compCaptor.getValue());
+        assertEquals(TASK_ID, taskCaptor.getValue());
+        assertEquals(WORKER_PORT, portCaptor.getValue());
+    }
+}

Reply via email to