You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@stormcrawler.apache.org by jn...@apache.org on 2026/04/07 17:41:00 UTC

(stormcrawler) branch main updated: #585 Add Storm V2 metrics support with backward-compatible bridge (#1846)

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

jnioche pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/stormcrawler.git


The following commit(s) were added to refs/heads/main by this push:
     new b4e97ec4 #585 Add Storm V2 metrics support with backward-compatible bridge (#1846)
b4e97ec4 is described below

commit b4e97ec4d2905e9ff24722ba8640245110eccd1e
Author: Richard Zowalla <13...@users.noreply.github.com>
AuthorDate: Tue Apr 7 19:40:55 2026 +0200

    #585 Add Storm V2 metrics support with backward-compatible bridge (#1846)
    
    * #585 Add Storm V2 metrics support with backward-compatible bridge
    
    Introduce a CrawlerMetrics factory that routes metric registration to
    Storm V1, V2 (Codahale/Dropwizard), or both APIs based on the config
    property `stormcrawler.metrics.version` ("v1" default, "v2", "both").
    This enables gradual migration from deprecated V1 metrics without
    breaking existing deployments or dashboards.
    
    - New metrics bridge infrastructure in core (ScopedCounter,
      ScopedReducedMetric interfaces with V1/V2/Dual implementations)
    - Migrated all bolt/spout metric registration across core and all
      external modules (opensearch, sql, solr, aws, tika, warc, urlfrontier)
    - Added V2 ScheduledStormReporter implementations for OpenSearch, SQL,
      and Solr that write the same document schema as V1 MetricsConsumer
    
    * Address reviewer comments
---
 .../org/apache/stormcrawler/bolt/FetcherBolt.java  |  53 ++--
 .../apache/stormcrawler/bolt/JSoupParserBolt.java  |   7 +-
 .../stormcrawler/bolt/SimpleFetcherBolt.java       |  52 ++--
 .../stormcrawler/bolt/SiteMapParserBolt.java       |  14 +-
 .../stormcrawler/bolt/URLPartitionerBolt.java      |   8 +-
 .../stormcrawler/metrics/CrawlerMetrics.java       | 315 +++++++++++++++++++++
 .../stormcrawler/metrics/DualCounterMetric.java    |  43 +++
 .../stormcrawler/metrics/DualReducedMetric.java    |  43 +++
 .../apache/stormcrawler/metrics/ScopedCounter.java |  39 +++
 .../stormcrawler/metrics/ScopedReducedMetric.java  |  34 +++
 .../stormcrawler/metrics/v1/V1CounterMetric.java   |  36 +++
 .../stormcrawler/metrics/v1/V1ReducedMetric.java   |  36 +++
 .../stormcrawler/metrics/v2/V2CounterMetric.java   |  50 ++++
 .../metrics/v2/V2HistogramReducedMetric.java       |  60 ++++
 .../metrics/v2/V2MeterReducedMetric.java           |  59 ++++
 .../persistence/AbstractQueryingSpout.java         |  26 +-
 .../persistence/AbstractStatusUpdaterBolt.java     |  36 ++-
 .../org/apache/stormcrawler/spout/MemorySpout.java |   3 +-
 core/src/main/resources/crawler-default.yaml       |   3 +
 .../java/org/apache/stormcrawler/TestUtil.java     |  47 +--
 .../stormcrawler/metrics/CrawlerMetricsTest.java   | 134 +++++++++
 .../metrics/DualCounterMetricTest.java             |  46 +++
 .../metrics/DualReducedMetricTest.java             |  45 +++
 .../metrics/v2/V2CounterMetricTest.java            |  82 ++++++
 .../metrics/v2/V2HistogramReducedMetricTest.java   |  85 ++++++
 .../metrics/v2/V2MeterReducedMetricTest.java       |  78 +++++
 .../aws/bolt/CloudSearchIndexerBolt.java           |   8 +-
 .../stormcrawler/aws/s3/AbstractS3CacheBolt.java   |   4 +-
 .../apache/stormcrawler/aws/s3/S3CacheChecker.java |   4 +-
 .../org/apache/stormcrawler/aws/s3/S3Cacher.java   |   6 +-
 .../stormcrawler/opensearch/bolt/DeletionBolt.java |   3 +-
 .../stormcrawler/opensearch/bolt/IndexerBolt.java  |  19 +-
 .../opensearch/metrics/MetricsReporter.java        | 221 +++++++++++++++
 .../opensearch/metrics/StatusMetricsBolt.java      |   9 +-
 .../opensearch/persistence/AggregationSpout.java   |   2 +-
 .../opensearch/persistence/StatusUpdaterBolt.java  |  23 +-
 .../opensearch/metrics/MetricsReporterTest.java    |  54 ++++
 .../apache/stormcrawler/solr/bolt/IndexerBolt.java |   7 +-
 .../stormcrawler/solr/metrics/MetricsReporter.java | 174 ++++++++++++
 .../stormcrawler/solr/persistence/SolrSpout.java   |   2 +-
 .../org/apache/stormcrawler/sql/IndexerBolt.java   |   7 +-
 .../java/org/apache/stormcrawler/sql/SQLSpout.java |   2 +-
 .../apache/stormcrawler/sql/StatusUpdaterBolt.java |   7 +-
 .../stormcrawler/sql/metrics/MetricsReporter.java  | 180 ++++++++++++
 .../org/apache/stormcrawler/tika/ParserBolt.java   |   7 +-
 external/urlfrontier/pom.xml                       |   2 +-
 .../urlfrontier/StatusUpdaterBolt.java             |   8 +-
 .../org/apache/stormcrawler/warc/WARCSpout.java    |   9 +-
 48 files changed, 2004 insertions(+), 188 deletions(-)

diff --git a/core/src/main/java/org/apache/stormcrawler/bolt/FetcherBolt.java b/core/src/main/java/org/apache/stormcrawler/bolt/FetcherBolt.java
index 3b8c89a5..c534e14f 100644
--- a/core/src/main/java/org/apache/stormcrawler/bolt/FetcherBolt.java
+++ b/core/src/main/java/org/apache/stormcrawler/bolt/FetcherBolt.java
@@ -48,9 +48,6 @@ import java.util.regex.Pattern;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.HttpHeaders;
 import org.apache.storm.Config;
-import org.apache.storm.metric.api.MeanReducer;
-import org.apache.storm.metric.api.MultiCountMetric;
-import org.apache.storm.metric.api.MultiReducedMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -61,13 +58,15 @@ import org.apache.storm.utils.TupleUtils;
 import org.apache.storm.utils.Utils;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.protocol.Protocol;
 import org.apache.stormcrawler.protocol.ProtocolFactory;
 import org.apache.stormcrawler.protocol.ProtocolResponse;
 import org.apache.stormcrawler.protocol.RobotRules;
 import org.apache.stormcrawler.util.ConfUtils;
-import org.apache.stormcrawler.util.PerSecondReducer;
 import org.apache.stormcrawler.util.URLUtil;
 import org.slf4j.LoggerFactory;
 
@@ -112,8 +111,8 @@ public class FetcherBolt extends StatusEmitterBolt {
 
     private FetchItemQueues fetchQueues;
 
-    private MultiCountMetric eventCounter;
-    private MultiReducedMetric averagedMetrics;
+    private ScopedCounter eventCounter;
+    private ScopedReducedMetric averagedMetrics;
 
     private ProtocolFactory protocolFactory;
 
@@ -121,7 +120,7 @@ public class FetcherBolt extends StatusEmitterBolt {
 
     boolean sitemapsAutoDiscovery = false;
 
-    private MultiReducedMetric perSecMetrics;
+    private ScopedReducedMetric perSecMetrics;
 
     private File debugfiletrigger;
 
@@ -909,42 +908,34 @@ public class FetcherBolt extends StatusEmitterBolt {
         // The data can be accessed by registering a "MetricConsumer" in the
         // topology
         this.eventCounter =
-                context.registerMetric(
-                        "fetcher_counter", new MultiCountMetric(), metricsTimeBucketSecs);
+                CrawlerMetrics.registerCounter(
+                        context, stormConf, "fetcher_counter", metricsTimeBucketSecs);
 
         // create gauges
-        context.registerMetric(
-                "activethreads",
-                () -> {
-                    return activeThreads.get();
-                },
-                metricsTimeBucketSecs);
+        CrawlerMetrics.registerGauge(
+                context, stormConf, "activethreads", activeThreads::get, metricsTimeBucketSecs);
 
-        context.registerMetric(
+        CrawlerMetrics.registerGauge(
+                context,
+                stormConf,
                 "in_queues",
-                () -> {
-                    return fetchQueues.inQueues.get();
-                },
+                () -> fetchQueues.inQueues.get(),
                 metricsTimeBucketSecs);
 
-        context.registerMetric(
+        CrawlerMetrics.registerGauge(
+                context,
+                stormConf,
                 "num_queues",
-                () -> {
-                    return fetchQueues.queues.size();
-                },
+                () -> fetchQueues.queues.size(),
                 metricsTimeBucketSecs);
 
         this.averagedMetrics =
-                context.registerMetric(
-                        "fetcher_average_perdoc",
-                        new MultiReducedMetric(new MeanReducer()),
-                        metricsTimeBucketSecs);
+                CrawlerMetrics.registerMeanMetric(
+                        context, stormConf, "fetcher_average_perdoc", metricsTimeBucketSecs);
 
         this.perSecMetrics =
-                context.registerMetric(
-                        "fetcher_average_persec",
-                        new MultiReducedMetric(new PerSecondReducer()),
-                        metricsTimeBucketSecs);
+                CrawlerMetrics.registerPerSecMetric(
+                        context, stormConf, "fetcher_average_persec", metricsTimeBucketSecs);
 
         protocolFactory = ProtocolFactory.getInstance(conf);
 
diff --git a/core/src/main/java/org/apache/stormcrawler/bolt/JSoupParserBolt.java b/core/src/main/java/org/apache/stormcrawler/bolt/JSoupParserBolt.java
index 73b3f453..1330af83 100644
--- a/core/src/main/java/org/apache/stormcrawler/bolt/JSoupParserBolt.java
+++ b/core/src/main/java/org/apache/stormcrawler/bolt/JSoupParserBolt.java
@@ -35,7 +35,6 @@ import java.util.Map;
 import java.util.stream.Stream;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.HttpHeaders;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -44,6 +43,8 @@ import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.parse.DocumentFragmentBuilder;
 import org.apache.stormcrawler.parse.JSoupFilter;
 import org.apache.stormcrawler.parse.JSoupFilters;
@@ -82,7 +83,7 @@ public class JSoupParserBolt extends StatusEmitterBolt {
 
     private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(JSoupParserBolt.class);
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private ParseFilter parseFilters = null;
 
@@ -132,7 +133,7 @@ public class JSoupParserBolt extends StatusEmitterBolt {
         super.prepare(conf, context, collector);
 
         eventCounter =
-                context.registerMetric(this.getClass().getSimpleName(), new MultiCountMetric(), 10);
+                CrawlerMetrics.registerCounter(context, conf, this.getClass().getSimpleName(), 10);
 
         parseFilters = ParseFilters.fromConf(conf);
 
diff --git a/core/src/main/java/org/apache/stormcrawler/bolt/SimpleFetcherBolt.java b/core/src/main/java/org/apache/stormcrawler/bolt/SimpleFetcherBolt.java
index 7beef050..da40b069 100644
--- a/core/src/main/java/org/apache/stormcrawler/bolt/SimpleFetcherBolt.java
+++ b/core/src/main/java/org/apache/stormcrawler/bolt/SimpleFetcherBolt.java
@@ -39,10 +39,6 @@ import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.HttpHeaders;
 import org.apache.storm.Config;
-import org.apache.storm.metric.api.IMetric;
-import org.apache.storm.metric.api.MeanReducer;
-import org.apache.storm.metric.api.MultiCountMetric;
-import org.apache.storm.metric.api.MultiReducedMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -52,13 +48,15 @@ import org.apache.storm.tuple.Values;
 import org.apache.storm.utils.Utils;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.protocol.Protocol;
 import org.apache.stormcrawler.protocol.ProtocolFactory;
 import org.apache.stormcrawler.protocol.ProtocolResponse;
 import org.apache.stormcrawler.protocol.RobotRules;
 import org.apache.stormcrawler.util.ConfUtils;
-import org.apache.stormcrawler.util.PerSecondReducer;
 import org.apache.stormcrawler.util.URLUtil;
 import org.slf4j.LoggerFactory;
 
@@ -85,9 +83,9 @@ public class SimpleFetcherBolt extends StatusEmitterBolt {
 
     private Config conf;
 
-    private MultiCountMetric eventCounter;
-    private MultiReducedMetric averagedMetrics;
-    private MultiReducedMetric perSecMetrics;
+    private ScopedCounter eventCounter;
+    private ScopedReducedMetric averagedMetrics;
+    private ScopedReducedMetric perSecMetrics;
 
     private ProtocolFactory protocolFactory;
 
@@ -174,40 +172,26 @@ public class SimpleFetcherBolt extends StatusEmitterBolt {
         int metricsTimeBucketSecs = ConfUtils.getInt(conf, "fetcher.metrics.time.bucket.secs", 10);
 
         this.eventCounter =
-                context.registerMetric(
-                        "fetcher_counter", new MultiCountMetric(), metricsTimeBucketSecs);
+                CrawlerMetrics.registerCounter(
+                        context, stormConf, "fetcher_counter", metricsTimeBucketSecs);
 
         this.averagedMetrics =
-                context.registerMetric(
-                        "fetcher_average",
-                        new MultiReducedMetric(new MeanReducer()),
-                        metricsTimeBucketSecs);
+                CrawlerMetrics.registerMeanMetric(
+                        context, stormConf, "fetcher_average", metricsTimeBucketSecs);
 
         this.perSecMetrics =
-                context.registerMetric(
-                        "fetcher_average_persec",
-                        new MultiReducedMetric(new PerSecondReducer()),
-                        metricsTimeBucketSecs);
+                CrawlerMetrics.registerPerSecMetric(
+                        context, stormConf, "fetcher_average_persec", metricsTimeBucketSecs);
 
         // create gauges
-        context.registerMetric(
-                "activethreads",
-                new IMetric() {
-                    @Override
-                    public Object getValueAndReset() {
-                        return activeThreads.get();
-                    }
-                },
-                metricsTimeBucketSecs);
+        CrawlerMetrics.registerGauge(
+                context, stormConf, "activethreads", activeThreads::get, metricsTimeBucketSecs);
 
-        context.registerMetric(
+        CrawlerMetrics.registerGauge(
+                context,
+                stormConf,
                 "throttler_size",
-                new IMetric() {
-                    @Override
-                    public Object getValueAndReset() {
-                        return throttler.estimatedSize();
-                    }
-                },
+                throttler::estimatedSize,
                 metricsTimeBucketSecs);
 
         protocolFactory = ProtocolFactory.getInstance(conf);
diff --git a/core/src/main/java/org/apache/stormcrawler/bolt/SiteMapParserBolt.java b/core/src/main/java/org/apache/stormcrawler/bolt/SiteMapParserBolt.java
index 4e77b0f2..66bea2af 100644
--- a/core/src/main/java/org/apache/stormcrawler/bolt/SiteMapParserBolt.java
+++ b/core/src/main/java/org/apache/stormcrawler/bolt/SiteMapParserBolt.java
@@ -42,10 +42,9 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.TimeZone;
+import java.util.function.Consumer;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.HttpHeaders;
-import org.apache.storm.metric.api.MeanReducer;
-import org.apache.storm.metric.api.ReducedMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -54,6 +53,7 @@ import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
 import org.apache.stormcrawler.parse.Outlink;
 import org.apache.stormcrawler.parse.ParseFilter;
 import org.apache.stormcrawler.parse.ParseFilters;
@@ -86,7 +86,7 @@ public class SiteMapParserBolt extends StatusEmitterBolt {
 
     private int maxOffsetGuess = 300;
 
-    private ReducedMetric averagedMetrics;
+    private Consumer<Number> averagedMetrics;
 
     /** Delay in minutes used for scheduling sub-sitemaps. */
     private int scheduleSitemapsWithDelay = -1;
@@ -194,7 +194,7 @@ public class SiteMapParserBolt extends StatusEmitterBolt {
             siteMap = parser.parseSiteMap(contentType, content, url1);
         }
         long end = System.currentTimeMillis();
-        averagedMetrics.update(end - start);
+        averagedMetrics.accept(end - start);
 
         List<Outlink> links = new ArrayList<>();
 
@@ -341,10 +341,8 @@ public class SiteMapParserBolt extends StatusEmitterBolt {
         parseFilters = ParseFilters.fromConf(stormConf);
         maxOffsetGuess = ConfUtils.getInt(stormConf, "sitemap.offset.guess", 300);
         averagedMetrics =
-                context.registerMetric(
-                        "sitemap_average_processing_time",
-                        new ReducedMetric(new MeanReducer()),
-                        30);
+                CrawlerMetrics.registerSingleMeanMetric(
+                        context, stormConf, "sitemap_average_processing_time", 30);
         scheduleSitemapsWithDelay =
                 ConfUtils.getInt(stormConf, "sitemap.schedule.delay", scheduleSitemapsWithDelay);
         List<String> extensionsStrings =
diff --git a/core/src/main/java/org/apache/stormcrawler/bolt/URLPartitionerBolt.java b/core/src/main/java/org/apache/stormcrawler/bolt/URLPartitionerBolt.java
index ad0deb52..cb510bab 100644
--- a/core/src/main/java/org/apache/stormcrawler/bolt/URLPartitionerBolt.java
+++ b/core/src/main/java/org/apache/stormcrawler/bolt/URLPartitionerBolt.java
@@ -25,7 +25,6 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import org.apache.commons.lang3.StringUtils;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -35,6 +34,8 @@ import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.util.ConfUtils;
 import org.apache.stormcrawler.util.URLUtil;
 import org.slf4j.Logger;
@@ -47,7 +48,7 @@ public class URLPartitionerBolt extends BaseRichBolt {
 
     private OutputCollector collector;
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private Map<String, String> cache;
 
@@ -165,7 +166,8 @@ public class URLPartitionerBolt extends BaseRichBolt {
         // system stream
         // The data can be accessed by registering a "MetricConsumer" in the
         // topology
-        this.eventCounter = context.registerMetric("URLPartitioner", new MultiCountMetric(), 10);
+        this.eventCounter =
+                CrawlerMetrics.registerCounter(context, stormConf, "URLPartitioner", 10);
 
         final int maxEntries = 500;
         cache =
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/CrawlerMetrics.java b/core/src/main/java/org/apache/stormcrawler/metrics/CrawlerMetrics.java
new file mode 100644
index 00000000..2b6bec6f
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/CrawlerMetrics.java
@@ -0,0 +1,315 @@
+/*
+ * 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.stormcrawler.metrics;
+
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.apache.storm.metric.api.IMetric;
+import org.apache.storm.metric.api.MeanReducer;
+import org.apache.storm.metric.api.MultiCountMetric;
+import org.apache.storm.metric.api.MultiReducedMetric;
+import org.apache.storm.metric.api.ReducedMetric;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.metrics.v1.V1CounterMetric;
+import org.apache.stormcrawler.metrics.v1.V1ReducedMetric;
+import org.apache.stormcrawler.metrics.v2.V2CounterMetric;
+import org.apache.stormcrawler.metrics.v2.V2HistogramReducedMetric;
+import org.apache.stormcrawler.metrics.v2.V2MeterReducedMetric;
+import org.apache.stormcrawler.util.CollectionMetric;
+import org.apache.stormcrawler.util.ConfUtils;
+import org.apache.stormcrawler.util.PerSecondReducer;
+
+/**
+ * Factory for creating metric instances that route to Storm V1, V2, or both metric APIs. The
+ * metrics version is controlled by the configuration property {@value #METRICS_VERSION_KEY}.
+ *
+ * <ul>
+ *   <li>{@code "v1"} (default) — uses the legacy Storm V1 metrics API
+ *   <li>{@code "v2"} — uses the Codahale/Dropwizard V2 metrics API
+ *   <li>{@code "both"} — registers with both V1 and V2 simultaneously for parallel operation during
+ *       migration
+ * </ul>
+ */
+public final class CrawlerMetrics {
+
+    /** Configuration key for selecting the metrics version: "v1", "v2", or "both". */
+    public static final String METRICS_VERSION_KEY = "stormcrawler.metrics.version";
+
+    private static final String VERSION_V1 = "v1";
+    private static final String VERSION_V2 = "v2";
+    private static final String VERSION_BOTH = "both";
+
+    private CrawlerMetrics() {}
+
+    /**
+     * Registers a scoped counter metric.
+     *
+     * @param context the Storm topology context
+     * @param stormConf the Storm configuration map
+     * @param name the metric name (e.g. "fetcher_counter")
+     * @param timeBucketSecs the V1 reporting interval in seconds (ignored for V2-only mode)
+     * @return a {@link ScopedCounter} that can be used via {@code scope("x").incrBy(n)}
+     */
+    public static ScopedCounter registerCounter(
+            TopologyContext context,
+            Map<String, Object> stormConf,
+            String name,
+            int timeBucketSecs) {
+
+        String version = getVersion(stormConf);
+
+        switch (version) {
+            case VERSION_V2:
+                return new V2CounterMetric(name, context);
+
+            case VERSION_BOTH:
+                ScopedCounter v1 = createV1Counter(context, name, timeBucketSecs);
+                ScopedCounter v2 = new V2CounterMetric(name, context);
+                return new DualCounterMetric(v1, v2);
+
+            case VERSION_V1:
+            default:
+                return createV1Counter(context, name, timeBucketSecs);
+        }
+    }
+
+    /**
+     * Registers a scoped reduced metric backed by a {@link MeanReducer}. In V2 mode, this uses a
+     * Codahale {@link com.codahale.metrics.Histogram}.
+     *
+     * @param context the Storm topology context
+     * @param stormConf the Storm configuration map
+     * @param name the metric name (e.g. "fetcher_average")
+     * @param timeBucketSecs the V1 reporting interval in seconds (ignored for V2-only mode)
+     * @return a {@link ScopedReducedMetric} that can be used via {@code scope("x").update(val)}
+     */
+    public static ScopedReducedMetric registerMeanMetric(
+            TopologyContext context,
+            Map<String, Object> stormConf,
+            String name,
+            int timeBucketSecs) {
+
+        String version = getVersion(stormConf);
+
+        switch (version) {
+            case VERSION_V2:
+                return new V2HistogramReducedMetric(name, context);
+
+            case VERSION_BOTH:
+                ScopedReducedMetric v1 = createV1MeanMetric(context, name, timeBucketSecs);
+                ScopedReducedMetric v2 = new V2HistogramReducedMetric(name, context);
+                return new DualReducedMetric(v1, v2);
+
+            case VERSION_V1:
+            default:
+                return createV1MeanMetric(context, name, timeBucketSecs);
+        }
+    }
+
+    /**
+     * Registers a scoped reduced metric backed by a {@link PerSecondReducer}. In V2 mode, this uses
+     * a Codahale {@link com.codahale.metrics.Meter} which natively tracks rate per second.
+     *
+     * @param context the Storm topology context
+     * @param stormConf the Storm configuration map
+     * @param name the metric name (e.g. "fetcher_average_persec")
+     * @param timeBucketSecs the V1 reporting interval in seconds (ignored for V2-only mode)
+     * @return a {@link ScopedReducedMetric} that can be used via {@code scope("x").update(val)}
+     */
+    public static ScopedReducedMetric registerPerSecMetric(
+            TopologyContext context,
+            Map<String, Object> stormConf,
+            String name,
+            int timeBucketSecs) {
+
+        String version = getVersion(stormConf);
+
+        switch (version) {
+            case VERSION_V2:
+                return new V2MeterReducedMetric(name, context);
+
+            case VERSION_BOTH:
+                ScopedReducedMetric v1 = createV1PerSecMetric(context, name, timeBucketSecs);
+                ScopedReducedMetric v2 = new V2MeterReducedMetric(name, context);
+                return new DualReducedMetric(v1, v2);
+
+            case VERSION_V1:
+            default:
+                return createV1PerSecMetric(context, name, timeBucketSecs);
+        }
+    }
+
+    /**
+     * Registers a gauge metric.
+     *
+     * @param context the Storm topology context
+     * @param stormConf the Storm configuration map
+     * @param name the metric name (e.g. "activethreads")
+     * @param supplier a supplier providing the current gauge value
+     * @param timeBucketSecs the V1 reporting interval in seconds (ignored for V2-only mode)
+     * @param <T> the gauge value type
+     */
+    public static <T> void registerGauge(
+            TopologyContext context,
+            Map<String, Object> stormConf,
+            String name,
+            Supplier<T> supplier,
+            int timeBucketSecs) {
+
+        String version = getVersion(stormConf);
+
+        boolean registerV1 = VERSION_V1.equals(version) || VERSION_BOTH.equals(version);
+        boolean registerV2 = VERSION_V2.equals(version) || VERSION_BOTH.equals(version);
+
+        if (registerV1) {
+            context.registerMetric(
+                    name,
+                    new IMetric() {
+                        @Override
+                        public Object getValueAndReset() {
+                            return supplier.get();
+                        }
+                    },
+                    timeBucketSecs);
+        }
+
+        if (registerV2) {
+            context.registerGauge(name, supplier::get);
+        }
+    }
+
+    /**
+     * Registers a single (non-scoped) mean metric, e.g. for tracking average processing time. In V1
+     * mode, this uses a {@link ReducedMetric} with {@link MeanReducer}. In V2 mode, this uses a
+     * Codahale {@link com.codahale.metrics.Histogram}.
+     *
+     * @param context the Storm topology context
+     * @param stormConf the Storm configuration map
+     * @param name the metric name (e.g. "sitemap_average_processing_time")
+     * @param timeBucketSecs the V1 reporting interval in seconds (ignored for V2-only mode)
+     * @return a {@link Consumer} that accepts numeric values to update the metric
+     */
+    public static Consumer<Number> registerSingleMeanMetric(
+            TopologyContext context,
+            Map<String, Object> stormConf,
+            String name,
+            int timeBucketSecs) {
+
+        String version = getVersion(stormConf);
+        boolean useV1 = VERSION_V1.equals(version) || VERSION_BOTH.equals(version);
+        boolean useV2 = VERSION_V2.equals(version) || VERSION_BOTH.equals(version);
+
+        Consumer<Number> v1Consumer = null;
+        Consumer<Number> v2Consumer = null;
+
+        if (useV1) {
+            ReducedMetric metric =
+                    context.registerMetric(
+                            name, new ReducedMetric(new MeanReducer()), timeBucketSecs);
+            v1Consumer = value -> metric.update(value);
+        }
+
+        if (useV2) {
+            com.codahale.metrics.Histogram histogram = context.registerHistogram(name);
+            v2Consumer = value -> histogram.update(value.longValue());
+        }
+
+        if (useV1 && useV2) {
+            final Consumer<Number> f1 = v1Consumer;
+            final Consumer<Number> f2 = v2Consumer;
+            return value -> {
+                f1.accept(value);
+                f2.accept(value);
+            };
+        }
+        return useV2 ? v2Consumer : v1Consumer;
+    }
+
+    /**
+     * Registers a collection metric for tracking timing measurements. In V1 mode, this uses {@link
+     * CollectionMetric}. In V2 mode, this uses a Codahale {@link com.codahale.metrics.Histogram}.
+     *
+     * @param context the Storm topology context
+     * @param stormConf the Storm configuration map
+     * @param name the metric name (e.g. "spout_query_time_msec")
+     * @param timeBucketSecs the V1 reporting interval in seconds (ignored for V2-only mode)
+     * @return a {@link Consumer} that accepts long measurement values
+     */
+    public static Consumer<Long> registerCollectionMetric(
+            TopologyContext context,
+            Map<String, Object> stormConf,
+            String name,
+            int timeBucketSecs) {
+
+        String version = getVersion(stormConf);
+        boolean useV1 = VERSION_V1.equals(version) || VERSION_BOTH.equals(version);
+        boolean useV2 = VERSION_V2.equals(version) || VERSION_BOTH.equals(version);
+
+        Consumer<Long> v1Consumer = null;
+        Consumer<Long> v2Consumer = null;
+
+        if (useV1) {
+            CollectionMetric metric = new CollectionMetric();
+            context.registerMetric(name, metric, timeBucketSecs);
+            v1Consumer = metric::addMeasurement;
+        }
+
+        if (useV2) {
+            com.codahale.metrics.Histogram histogram = context.registerHistogram(name);
+            v2Consumer = histogram::update;
+        }
+
+        if (useV1 && useV2) {
+            final Consumer<Long> f1 = v1Consumer;
+            final Consumer<Long> f2 = v2Consumer;
+            return value -> {
+                f1.accept(value);
+                f2.accept(value);
+            };
+        }
+        return useV2 ? v2Consumer : v1Consumer;
+    }
+
+    private static ScopedCounter createV1Counter(
+            TopologyContext context, String name, int timeBucketSecs) {
+        MultiCountMetric metric =
+                context.registerMetric(name, new MultiCountMetric(), timeBucketSecs);
+        return new V1CounterMetric(metric);
+    }
+
+    private static ScopedReducedMetric createV1MeanMetric(
+            TopologyContext context, String name, int timeBucketSecs) {
+        MultiReducedMetric metric =
+                context.registerMetric(
+                        name, new MultiReducedMetric(new MeanReducer()), timeBucketSecs);
+        return new V1ReducedMetric(metric);
+    }
+
+    private static ScopedReducedMetric createV1PerSecMetric(
+            TopologyContext context, String name, int timeBucketSecs) {
+        MultiReducedMetric metric =
+                context.registerMetric(
+                        name, new MultiReducedMetric(new PerSecondReducer()), timeBucketSecs);
+        return new V1ReducedMetric(metric);
+    }
+
+    private static String getVersion(Map<String, Object> stormConf) {
+        return ConfUtils.getString(stormConf, METRICS_VERSION_KEY, VERSION_V1);
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/DualCounterMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/DualCounterMetric.java
new file mode 100644
index 00000000..eb81595c
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/DualCounterMetric.java
@@ -0,0 +1,43 @@
+/*
+ * 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.stormcrawler.metrics;
+
+/**
+ * Forwards counter operations to both a V1 and a V2 {@link ScopedCounter} simultaneously, allowing
+ * both metric pipelines to receive data during migration.
+ */
+public class DualCounterMetric implements ScopedCounter {
+
+    private final ScopedCounter v1;
+    private final ScopedCounter v2;
+
+    public DualCounterMetric(ScopedCounter v1, ScopedCounter v2) {
+        this.v1 = v1;
+        this.v2 = v2;
+    }
+
+    @Override
+    public CountHandle scope(String scopeName) {
+        CountHandle v1Handle = v1.scope(scopeName);
+        CountHandle v2Handle = v2.scope(scopeName);
+        return incrementBy -> {
+            v1Handle.incrBy(incrementBy);
+            v2Handle.incrBy(incrementBy);
+        };
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/DualReducedMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/DualReducedMetric.java
new file mode 100644
index 00000000..da12f634
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/DualReducedMetric.java
@@ -0,0 +1,43 @@
+/*
+ * 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.stormcrawler.metrics;
+
+/**
+ * Forwards reduced metric operations to both a V1 and a V2 {@link ScopedReducedMetric}
+ * simultaneously, allowing both metric pipelines to receive data during migration.
+ */
+public class DualReducedMetric implements ScopedReducedMetric {
+
+    private final ScopedReducedMetric v1;
+    private final ScopedReducedMetric v2;
+
+    public DualReducedMetric(ScopedReducedMetric v1, ScopedReducedMetric v2) {
+        this.v1 = v1;
+        this.v2 = v2;
+    }
+
+    @Override
+    public ReduceHandle scope(String scopeName) {
+        ReduceHandle v1Handle = v1.scope(scopeName);
+        ReduceHandle v2Handle = v2.scope(scopeName);
+        return value -> {
+            v1Handle.update(value);
+            v2Handle.update(value);
+        };
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/ScopedCounter.java b/core/src/main/java/org/apache/stormcrawler/metrics/ScopedCounter.java
new file mode 100644
index 00000000..9c53e05f
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/ScopedCounter.java
@@ -0,0 +1,39 @@
+/*
+ * 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.stormcrawler.metrics;
+
+/**
+ * Abstraction for a scoped counter metric, compatible with both Storm V1 {@link
+ * org.apache.storm.metric.api.MultiCountMetric} and V2 Codahale {@link
+ * com.codahale.metrics.Counter} APIs.
+ */
+public interface ScopedCounter {
+
+    /** Returns a counter handle for the given scope name. */
+    CountHandle scope(String scopeName);
+
+    /** A handle to increment a counter within a specific scope. */
+    interface CountHandle {
+
+        void incrBy(long incrementBy);
+
+        default void incr() {
+            incrBy(1);
+        }
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/ScopedReducedMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/ScopedReducedMetric.java
new file mode 100644
index 00000000..60280dbe
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/ScopedReducedMetric.java
@@ -0,0 +1,34 @@
+/*
+ * 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.stormcrawler.metrics;
+
+/**
+ * Abstraction for a scoped reduced metric (mean, per-second rate, etc.), compatible with both Storm
+ * V1 {@link org.apache.storm.metric.api.MultiReducedMetric} and V2 Codahale metric APIs.
+ */
+public interface ScopedReducedMetric {
+
+    /** Returns a metric handle for the given scope name. */
+    ReduceHandle scope(String scopeName);
+
+    /** A handle to update a reduced metric within a specific scope. */
+    interface ReduceHandle {
+
+        void update(Object value);
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/v1/V1CounterMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/v1/V1CounterMetric.java
new file mode 100644
index 00000000..43875478
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/v1/V1CounterMetric.java
@@ -0,0 +1,36 @@
+/*
+ * 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.stormcrawler.metrics.v1;
+
+import org.apache.storm.metric.api.MultiCountMetric;
+import org.apache.stormcrawler.metrics.ScopedCounter;
+
+/** V1 implementation of {@link ScopedCounter} backed by {@link MultiCountMetric}. */
+public class V1CounterMetric implements ScopedCounter {
+
+    private final MultiCountMetric delegate;
+
+    public V1CounterMetric(MultiCountMetric delegate) {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public CountHandle scope(String scopeName) {
+        return delegate.scope(scopeName)::incrBy;
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/v1/V1ReducedMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/v1/V1ReducedMetric.java
new file mode 100644
index 00000000..9b688973
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/v1/V1ReducedMetric.java
@@ -0,0 +1,36 @@
+/*
+ * 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.stormcrawler.metrics.v1;
+
+import org.apache.storm.metric.api.MultiReducedMetric;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
+
+/** V1 implementation of {@link ScopedReducedMetric} backed by {@link MultiReducedMetric}. */
+public class V1ReducedMetric implements ScopedReducedMetric {
+
+    private final MultiReducedMetric delegate;
+
+    public V1ReducedMetric(MultiReducedMetric delegate) {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public ReduceHandle scope(String scopeName) {
+        return delegate.scope(scopeName)::update;
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2CounterMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2CounterMetric.java
new file mode 100644
index 00000000..96d1e6be
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2CounterMetric.java
@@ -0,0 +1,50 @@
+/*
+ * 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.stormcrawler.metrics.v2;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.metrics.ScopedCounter;
+
+/**
+ * V2 implementation of {@link ScopedCounter} using Codahale {@link com.codahale.metrics.Counter}
+ * instances registered via the Storm V2 metrics API.
+ */
+public class V2CounterMetric implements ScopedCounter {
+
+    private final String baseName;
+    private final TopologyContext context;
+    private final ConcurrentMap<String, CountHandle> handles = new ConcurrentHashMap<>();
+
+    public V2CounterMetric(String baseName, TopologyContext context) {
+        this.baseName = baseName;
+        this.context = context;
+    }
+
+    @Override
+    public CountHandle scope(String scopeName) {
+        return handles.computeIfAbsent(
+                scopeName,
+                name -> {
+                    String metricName = baseName + "." + name;
+                    com.codahale.metrics.Counter counter = context.registerCounter(metricName);
+                    return counter::inc;
+                });
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2HistogramReducedMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2HistogramReducedMetric.java
new file mode 100644
index 00000000..5552050d
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2HistogramReducedMetric.java
@@ -0,0 +1,60 @@
+/*
+ * 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.stormcrawler.metrics.v2;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
+
+/**
+ * V2 implementation of {@link ScopedReducedMetric} using Codahale {@link
+ * com.codahale.metrics.Histogram} instances. Used as the V2 replacement for {@code
+ * MultiReducedMetric(MeanReducer)}.
+ */
+public class V2HistogramReducedMetric implements ScopedReducedMetric {
+
+    private final String baseName;
+    private final TopologyContext context;
+    private final ConcurrentMap<String, ReduceHandle> handles = new ConcurrentHashMap<>();
+
+    public V2HistogramReducedMetric(String baseName, TopologyContext context) {
+        this.baseName = baseName;
+        this.context = context;
+    }
+
+    @Override
+    public ReduceHandle scope(String scopeName) {
+        return handles.computeIfAbsent(
+                scopeName,
+                name -> {
+                    String metricName = baseName + "." + name;
+                    com.codahale.metrics.Histogram histogram =
+                            context.registerHistogram(metricName);
+                    return value -> histogram.update(toLong(value));
+                });
+    }
+
+    private static long toLong(Object value) {
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        throw new IllegalArgumentException(
+                "V2HistogramReducedMetric: unsupported value type " + value.getClass());
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2MeterReducedMetric.java b/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2MeterReducedMetric.java
new file mode 100644
index 00000000..d276609a
--- /dev/null
+++ b/core/src/main/java/org/apache/stormcrawler/metrics/v2/V2MeterReducedMetric.java
@@ -0,0 +1,59 @@
+/*
+ * 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.stormcrawler.metrics.v2;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
+
+/**
+ * V2 implementation of {@link ScopedReducedMetric} using Codahale {@link
+ * com.codahale.metrics.Meter} instances. Used as the V2 replacement for {@code
+ * MultiReducedMetric(PerSecondReducer)}.
+ */
+public class V2MeterReducedMetric implements ScopedReducedMetric {
+
+    private final String baseName;
+    private final TopologyContext context;
+    private final ConcurrentMap<String, ReduceHandle> handles = new ConcurrentHashMap<>();
+
+    public V2MeterReducedMetric(String baseName, TopologyContext context) {
+        this.baseName = baseName;
+        this.context = context;
+    }
+
+    @Override
+    public ReduceHandle scope(String scopeName) {
+        return handles.computeIfAbsent(
+                scopeName,
+                name -> {
+                    String metricName = baseName + "." + name;
+                    com.codahale.metrics.Meter meter = context.registerMeter(metricName);
+                    return value -> meter.mark(toLong(value));
+                });
+    }
+
+    private static long toLong(Object value) {
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        throw new IllegalArgumentException(
+                "V2MeterReducedMetric: unsupported value type " + value.getClass());
+    }
+}
diff --git a/core/src/main/java/org/apache/stormcrawler/persistence/AbstractQueryingSpout.java b/core/src/main/java/org/apache/stormcrawler/persistence/AbstractQueryingSpout.java
index e13e953b..a1a164a2 100644
--- a/core/src/main/java/org/apache/stormcrawler/persistence/AbstractQueryingSpout.java
+++ b/core/src/main/java/org/apache/stormcrawler/persistence/AbstractQueryingSpout.java
@@ -26,15 +26,16 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.apache.storm.metric.api.MultiCountMetric;
+import java.util.function.Consumer;
 import org.apache.storm.spout.SpoutOutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
 import org.apache.storm.topology.base.BaseRichSpout;
 import org.apache.storm.tuple.Fields;
 import org.apache.storm.utils.Utils;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.persistence.urlbuffer.URLBuffer;
-import org.apache.stormcrawler.util.CollectionMetric;
 import org.apache.stormcrawler.util.ConfUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -85,7 +86,7 @@ public abstract class AbstractQueryingSpout extends BaseRichSpout {
 
     private long timestampEmptyBuffer = -1;
 
-    protected MultiCountMetric eventCounter;
+    protected ScopedCounter eventCounter;
 
     protected URLBuffer buffer;
 
@@ -94,7 +95,7 @@ public abstract class AbstractQueryingSpout extends BaseRichSpout {
     /** Required for implementations doing asynchronous calls. */
     protected AtomicBoolean isInQuery = new AtomicBoolean(false);
 
-    protected CollectionMetric queryTimes;
+    protected Consumer<Long> queryTimes;
 
     @Override
     public void open(
@@ -111,18 +112,21 @@ public abstract class AbstractQueryingSpout extends BaseRichSpout {
 
         beingProcessed = new InProcessMap<>(ttlPurgatory, TimeUnit.SECONDS);
 
-        eventCounter = context.registerMetric("counters", new MultiCountMetric(), 10);
+        eventCounter = CrawlerMetrics.registerCounter(context, stormConf, "counters", 10);
 
         buffer = URLBuffer.createInstance(stormConf);
 
-        context.registerMetric("buffer_size", () -> buffer.size(), 10);
-        context.registerMetric("numQueues", () -> buffer.numQueues(), 10);
+        CrawlerMetrics.registerGauge(context, stormConf, "buffer_size", buffer::size, 10);
+        CrawlerMetrics.registerGauge(context, stormConf, "numQueues", buffer::numQueues, 10);
 
-        context.registerMetric("beingProcessed", () -> beingProcessed.size(), 10);
-        context.registerMetric("inPurgatory", () -> beingProcessed.inCache(), 10);
+        CrawlerMetrics.registerGauge(
+                context, stormConf, "beingProcessed", beingProcessed::size, 10);
+        CrawlerMetrics.registerGauge(
+                context, stormConf, "inPurgatory", beingProcessed::inCache, 10);
 
-        queryTimes = new CollectionMetric();
-        context.registerMetric("spout_query_time_msec", queryTimes, 10);
+        queryTimes =
+                CrawlerMetrics.registerCollectionMetric(
+                        context, stormConf, "spout_query_time_msec", 10);
 
         resetFetchDateAfterNSecs =
                 ConfUtils.getInt(stormConf, resetFetchDateParamName, resetFetchDateAfterNSecs);
diff --git a/core/src/main/java/org/apache/stormcrawler/persistence/AbstractStatusUpdaterBolt.java b/core/src/main/java/org/apache/stormcrawler/persistence/AbstractStatusUpdaterBolt.java
index 0eb2d2d7..ee3f648f 100644
--- a/core/src/main/java/org/apache/stormcrawler/persistence/AbstractStatusUpdaterBolt.java
+++ b/core/src/main/java/org/apache/stormcrawler/persistence/AbstractStatusUpdaterBolt.java
@@ -23,11 +23,9 @@ import java.time.Instant;
 import java.time.format.DateTimeFormatter;
 import java.util.Calendar;
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import org.apache.commons.lang3.time.DateUtils;
-import org.apache.storm.metric.api.IMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -37,6 +35,7 @@ import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
 import org.apache.stormcrawler.util.ConfUtils;
 import org.apache.stormcrawler.util.MetadataTransfer;
 import org.slf4j.Logger;
@@ -109,21 +108,28 @@ public abstract class AbstractStatusUpdaterBolt extends BaseRichBolt {
             String spec = ConfUtils.getString(stormConf, cacheConfigParamName);
             cache = Caffeine.from(spec).build();
 
-            context.registerMetric(
-                    "cache",
-                    new IMetric() {
-                        @Override
-                        public Object getValueAndReset() {
-                            Map<String, Long> statsMap = new HashMap<>();
-                            statsMap.put("hits", cacheHits);
-                            statsMap.put("misses", cacheMisses);
-                            statsMap.put("size", cache.estimatedSize());
-                            cacheHits = 0;
-                            cacheMisses = 0;
-                            return statsMap;
-                        }
+            CrawlerMetrics.registerGauge(
+                    context,
+                    stormConf,
+                    "cache.hits",
+                    () -> {
+                        long v = cacheHits;
+                        cacheHits = 0;
+                        return v;
                     },
                     30);
+            CrawlerMetrics.registerGauge(
+                    context,
+                    stormConf,
+                    "cache.misses",
+                    () -> {
+                        long v = cacheMisses;
+                        cacheMisses = 0;
+                        return v;
+                    },
+                    30);
+            CrawlerMetrics.registerGauge(
+                    context, stormConf, "cache.size", cache::estimatedSize, 30);
         }
 
         maxFetchErrors = ConfUtils.getInt(stormConf, maxFetchErrorsParamName, 3);
diff --git a/core/src/main/java/org/apache/stormcrawler/spout/MemorySpout.java b/core/src/main/java/org/apache/stormcrawler/spout/MemorySpout.java
index 4c4480bb..221dae68 100644
--- a/core/src/main/java/org/apache/stormcrawler/spout/MemorySpout.java
+++ b/core/src/main/java/org/apache/stormcrawler/spout/MemorySpout.java
@@ -31,6 +31,7 @@ import org.apache.storm.topology.base.BaseRichSpout;
 import org.apache.storm.tuple.Fields;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.util.StringTabScheme;
 import org.slf4j.Logger;
@@ -99,7 +100,7 @@ public class MemorySpout extends BaseRichSpout {
                     scheme.deserialize(ByteBuffer.wrap(u.getBytes(StandardCharsets.UTF_8)));
             add((String) tuple.get(0), (Metadata) tuple.get(1), now);
         }
-        context.registerMetric("queue_size", () -> queue.size(), 10);
+        CrawlerMetrics.registerGauge(context, conf, "queue_size", queue::size, 10);
     }
 
     @Override
diff --git a/core/src/main/resources/crawler-default.yaml b/core/src/main/resources/crawler-default.yaml
index 27e62979..d024ed94 100644
--- a/core/src/main/resources/crawler-default.yaml
+++ b/core/src/main/resources/crawler-default.yaml
@@ -55,6 +55,9 @@ config:
   # time bucket to use for the metrics sent by the Fetcher
   fetcher.metrics.time.bucket.secs: 10
 
+  # metrics version: "v1" (legacy), "v2" (Storm V2 native), or "both"
+  stormcrawler.metrics.version: "v1"
+
   # SimpleFetcherBolt: if the delay required by the politeness
   # is above this value, the tuple is sent back to the Storm queue
   # for the bolt on the _throttle_ stream (in msec)
diff --git a/core/src/test/java/org/apache/stormcrawler/TestUtil.java b/core/src/test/java/org/apache/stormcrawler/TestUtil.java
index c226cfc1..058ef862 100644
--- a/core/src/test/java/org/apache/stormcrawler/TestUtil.java
+++ b/core/src/test/java/org/apache/stormcrawler/TestUtil.java
@@ -39,15 +39,7 @@ public class TestUtil {
 
     public static TopologyContext getMockedTopologyContext() {
         TopologyContext context = mock(TopologyContext.class);
-        when(context.registerMetric(anyString(), any(IMetric.class), anyInt()))
-                .thenAnswer(
-                        new Answer<IMetric>() {
-
-                            @Override
-                            public IMetric answer(InvocationOnMock invocation) throws Throwable {
-                                return invocation.getArgument(1, IMetric.class);
-                            }
-                        });
+        mockMetricRegistration(context);
         return context;
     }
 
@@ -63,15 +55,7 @@ public class TestUtil {
             int taskIndex, int totalTasks, String componentId) {
         TopologyContext context = mock(TopologyContext.class);
 
-        // Mock metric registration
-        when(context.registerMetric(anyString(), any(IMetric.class), anyInt()))
-                .thenAnswer(
-                        new Answer<IMetric>() {
-                            @Override
-                            public IMetric answer(InvocationOnMock invocation) throws Throwable {
-                                return invocation.getArgument(1, IMetric.class);
-                            }
-                        });
+        mockMetricRegistration(context);
 
         // Mock task information for bucket assignment
         when(context.getThisTaskIndex()).thenReturn(taskIndex);
@@ -87,6 +71,33 @@ public class TestUtil {
         return context;
     }
 
+    /** Sets up mock responses for both V1 and V2 metric registration on a TopologyContext. */
+    private static void mockMetricRegistration(TopologyContext context) {
+        // V1 metric registration
+        when(context.registerMetric(anyString(), any(IMetric.class), anyInt()))
+                .thenAnswer(
+                        new Answer<IMetric>() {
+                            @Override
+                            public IMetric answer(InvocationOnMock invocation) throws Throwable {
+                                return invocation.getArgument(1, IMetric.class);
+                            }
+                        });
+
+        // V2 metric registration
+        when(context.registerCounter(anyString()))
+                .thenAnswer(invocation -> new com.codahale.metrics.Counter());
+        when(context.registerHistogram(anyString()))
+                .thenAnswer(
+                        invocation ->
+                                new com.codahale.metrics.Histogram(
+                                        new com.codahale.metrics.ExponentiallyDecayingReservoir()));
+        when(context.registerMeter(anyString()))
+                .thenAnswer(invocation -> new com.codahale.metrics.Meter());
+        when(context.registerGauge(anyString(), any(com.codahale.metrics.Gauge.class)))
+                .thenAnswer(
+                        invocation -> invocation.getArgument(1, com.codahale.metrics.Gauge.class));
+    }
+
     public static Tuple getMockedTestTuple(String url, String content, Metadata metadata) {
         Tuple tuple = mock(Tuple.class);
         when(tuple.getStringByField("url")).thenReturn(url);
diff --git a/core/src/test/java/org/apache/stormcrawler/metrics/CrawlerMetricsTest.java b/core/src/test/java/org/apache/stormcrawler/metrics/CrawlerMetricsTest.java
new file mode 100644
index 00000000..4202ffd5
--- /dev/null
+++ b/core/src/test/java/org/apache/stormcrawler/metrics/CrawlerMetricsTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.stormcrawler.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.Map;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.TestUtil;
+import org.apache.stormcrawler.metrics.v1.V1CounterMetric;
+import org.apache.stormcrawler.metrics.v1.V1ReducedMetric;
+import org.apache.stormcrawler.metrics.v2.V2CounterMetric;
+import org.apache.stormcrawler.metrics.v2.V2HistogramReducedMetric;
+import org.apache.stormcrawler.metrics.v2.V2MeterReducedMetric;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class CrawlerMetricsTest {
+
+    private TopologyContext context;
+
+    @BeforeEach
+    void setUp() {
+        context = TestUtil.getMockedTopologyContext();
+    }
+
+    // --- registerCounter routing ---
+
+    @Test
+    void registerCounterDefaultReturnsV1() {
+        Map<String, Object> conf = Map.of();
+        ScopedCounter counter = CrawlerMetrics.registerCounter(context, conf, "test", 10);
+        assertInstanceOf(V1CounterMetric.class, counter);
+    }
+
+    @Test
+    void registerCounterV1ReturnsV1() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "v1");
+        ScopedCounter counter = CrawlerMetrics.registerCounter(context, conf, "test", 10);
+        assertInstanceOf(V1CounterMetric.class, counter);
+    }
+
+    @Test
+    void registerCounterV2ReturnsV2() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "v2");
+        ScopedCounter counter = CrawlerMetrics.registerCounter(context, conf, "test", 10);
+        assertInstanceOf(V2CounterMetric.class, counter);
+    }
+
+    @Test
+    void registerCounterBothReturnsDual() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "both");
+        ScopedCounter counter = CrawlerMetrics.registerCounter(context, conf, "test", 10);
+        assertInstanceOf(DualCounterMetric.class, counter);
+    }
+
+    // --- registerMeanMetric routing ---
+
+    @Test
+    void registerMeanMetricDefaultReturnsV1() {
+        Map<String, Object> conf = Map.of();
+        ScopedReducedMetric metric = CrawlerMetrics.registerMeanMetric(context, conf, "test", 10);
+        assertInstanceOf(V1ReducedMetric.class, metric);
+    }
+
+    @Test
+    void registerMeanMetricV2ReturnsHistogram() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "v2");
+        ScopedReducedMetric metric = CrawlerMetrics.registerMeanMetric(context, conf, "test", 10);
+        assertInstanceOf(V2HistogramReducedMetric.class, metric);
+    }
+
+    @Test
+    void registerMeanMetricBothReturnsDual() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "both");
+        ScopedReducedMetric metric = CrawlerMetrics.registerMeanMetric(context, conf, "test", 10);
+        assertInstanceOf(DualReducedMetric.class, metric);
+    }
+
+    // --- registerPerSecMetric routing ---
+
+    @Test
+    void registerPerSecMetricDefaultReturnsV1() {
+        Map<String, Object> conf = Map.of();
+        ScopedReducedMetric metric = CrawlerMetrics.registerPerSecMetric(context, conf, "test", 10);
+        assertInstanceOf(V1ReducedMetric.class, metric);
+    }
+
+    @Test
+    void registerPerSecMetricV2ReturnsMeter() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "v2");
+        ScopedReducedMetric metric = CrawlerMetrics.registerPerSecMetric(context, conf, "test", 10);
+        assertInstanceOf(V2MeterReducedMetric.class, metric);
+    }
+
+    @Test
+    void registerPerSecMetricBothReturnsDual() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "both");
+        ScopedReducedMetric metric = CrawlerMetrics.registerPerSecMetric(context, conf, "test", 10);
+        assertInstanceOf(DualReducedMetric.class, metric);
+    }
+
+    // --- registerSingleMeanMetric ---
+
+    @Test
+    void registerSingleMeanMetricReturnsConsumer() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "v2");
+        assertNotNull(CrawlerMetrics.registerSingleMeanMetric(context, conf, "test", 10));
+    }
+
+    // --- registerCollectionMetric ---
+
+    @Test
+    void registerCollectionMetricReturnsConsumer() {
+        Map<String, Object> conf = Map.of(CrawlerMetrics.METRICS_VERSION_KEY, "v2");
+        assertNotNull(CrawlerMetrics.registerCollectionMetric(context, conf, "test", 10));
+    }
+}
diff --git a/core/src/test/java/org/apache/stormcrawler/metrics/DualCounterMetricTest.java b/core/src/test/java/org/apache/stormcrawler/metrics/DualCounterMetricTest.java
new file mode 100644
index 00000000..28cbebd7
--- /dev/null
+++ b/core/src/test/java/org/apache/stormcrawler/metrics/DualCounterMetricTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.stormcrawler.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.Test;
+
+class DualCounterMetricTest {
+
+    @Test
+    void scopeDelegatesToBothCounters() {
+        AtomicLong v1Total = new AtomicLong();
+        AtomicLong v2Total = new AtomicLong();
+
+        ScopedCounter v1 = scopeName -> v1Total::addAndGet;
+        ScopedCounter v2 = scopeName -> v2Total::addAndGet;
+
+        DualCounterMetric dual = new DualCounterMetric(v1, v2);
+        ScopedCounter.CountHandle handle = dual.scope("test");
+
+        handle.incrBy(5);
+        assertEquals(5, v1Total.get());
+        assertEquals(5, v2Total.get());
+
+        handle.incrBy(3);
+        assertEquals(8, v1Total.get());
+        assertEquals(8, v2Total.get());
+    }
+}
diff --git a/core/src/test/java/org/apache/stormcrawler/metrics/DualReducedMetricTest.java b/core/src/test/java/org/apache/stormcrawler/metrics/DualReducedMetricTest.java
new file mode 100644
index 00000000..0f9cf5c5
--- /dev/null
+++ b/core/src/test/java/org/apache/stormcrawler/metrics/DualReducedMetricTest.java
@@ -0,0 +1,45 @@
+/*
+ * 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.stormcrawler.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class DualReducedMetricTest {
+
+    @Test
+    void scopeDelegatesToBothReducedMetrics() {
+        List<Object> v1Values = new ArrayList<>();
+        List<Object> v2Values = new ArrayList<>();
+
+        ScopedReducedMetric v1 = scopeName -> v1Values::add;
+        ScopedReducedMetric v2 = scopeName -> v2Values::add;
+
+        DualReducedMetric dual = new DualReducedMetric(v1, v2);
+        ScopedReducedMetric.ReduceHandle handle = dual.scope("test");
+
+        handle.update(42);
+        handle.update(100);
+
+        assertEquals(List.of(42, 100), v1Values);
+        assertEquals(List.of(42, 100), v2Values);
+    }
+}
diff --git a/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2CounterMetricTest.java b/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2CounterMetricTest.java
new file mode 100644
index 00000000..feb4b2a1
--- /dev/null
+++ b/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2CounterMetricTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.stormcrawler.metrics.v2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.codahale.metrics.Counter;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.metrics.ScopedCounter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class V2CounterMetricTest {
+
+    private TopologyContext context;
+    private Counter counter;
+    private V2CounterMetric metric;
+
+    @BeforeEach
+    void setUp() {
+        context = mock(TopologyContext.class);
+        counter = new Counter();
+        when(context.registerCounter(anyString())).thenReturn(counter);
+        metric = new V2CounterMetric("test_counter", context);
+    }
+
+    @Test
+    void scopeRegistersCounterWithComposedName() {
+        metric.scope("myScope");
+        verify(context).registerCounter("test_counter.myScope");
+    }
+
+    @Test
+    void scopeReturnsSameHandleForSameName() {
+        ScopedCounter.CountHandle first = metric.scope("s1");
+        ScopedCounter.CountHandle second = metric.scope("s1");
+        assertSame(first, second);
+    }
+
+    @Test
+    void incrByDelegatesToCodahaleCounter() {
+        ScopedCounter.CountHandle handle = metric.scope("s1");
+        handle.incrBy(7);
+        assertEquals(7, counter.getCount());
+
+        handle.incr();
+        assertEquals(8, counter.getCount());
+    }
+
+    @Test
+    void differentScopesGetDifferentCounters() {
+        Counter counter2 = new Counter();
+        when(context.registerCounter("test_counter.a")).thenReturn(counter);
+        when(context.registerCounter("test_counter.b")).thenReturn(counter2);
+
+        metric.scope("a").incrBy(3);
+        metric.scope("b").incrBy(5);
+
+        assertEquals(3, counter.getCount());
+        assertEquals(5, counter2.getCount());
+    }
+}
diff --git a/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2HistogramReducedMetricTest.java b/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2HistogramReducedMetricTest.java
new file mode 100644
index 00000000..751c828a
--- /dev/null
+++ b/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2HistogramReducedMetricTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.stormcrawler.metrics.v2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Histogram;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class V2HistogramReducedMetricTest {
+
+    private TopologyContext context;
+    private Histogram histogram;
+    private V2HistogramReducedMetric metric;
+
+    @BeforeEach
+    void setUp() {
+        context = mock(TopologyContext.class);
+        histogram = new Histogram(new ExponentiallyDecayingReservoir());
+        when(context.registerHistogram(anyString())).thenReturn(histogram);
+        metric = new V2HistogramReducedMetric("test_hist", context);
+    }
+
+    @Test
+    void scopeRegistersHistogramWithComposedName() {
+        metric.scope("myScope");
+        verify(context).registerHistogram("test_hist.myScope");
+    }
+
+    @Test
+    void scopeReturnsSameHandleForSameName() {
+        ScopedReducedMetric.ReduceHandle first = metric.scope("s1");
+        ScopedReducedMetric.ReduceHandle second = metric.scope("s1");
+        assertSame(first, second);
+    }
+
+    @Test
+    void updateAcceptsIntegerValues() {
+        metric.scope("s1").update(42);
+        assertEquals(1, histogram.getCount());
+    }
+
+    @Test
+    void updateAcceptsLongValues() {
+        metric.scope("s1").update(100L);
+        assertEquals(1, histogram.getCount());
+    }
+
+    @Test
+    void updateAcceptsDoubleValues() {
+        metric.scope("s1").update(3.14);
+        assertEquals(1, histogram.getCount());
+    }
+
+    @Test
+    void updateThrowsOnNonNumericValue() {
+        ScopedReducedMetric.ReduceHandle handle = metric.scope("s1");
+        assertThrows(IllegalArgumentException.class, () -> handle.update("not a number"));
+    }
+}
diff --git a/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2MeterReducedMetricTest.java b/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2MeterReducedMetricTest.java
new file mode 100644
index 00000000..de192724
--- /dev/null
+++ b/core/src/test/java/org/apache/stormcrawler/metrics/v2/V2MeterReducedMetricTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.stormcrawler.metrics.v2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.codahale.metrics.Meter;
+import org.apache.storm.task.TopologyContext;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class V2MeterReducedMetricTest {
+
+    private TopologyContext context;
+    private Meter meter;
+    private V2MeterReducedMetric metric;
+
+    @BeforeEach
+    void setUp() {
+        context = mock(TopologyContext.class);
+        meter = new Meter();
+        when(context.registerMeter(anyString())).thenReturn(meter);
+        metric = new V2MeterReducedMetric("test_meter", context);
+    }
+
+    @Test
+    void scopeRegistersMeterWithComposedName() {
+        metric.scope("myScope");
+        verify(context).registerMeter("test_meter.myScope");
+    }
+
+    @Test
+    void scopeReturnsSameHandleForSameName() {
+        ScopedReducedMetric.ReduceHandle first = metric.scope("s1");
+        ScopedReducedMetric.ReduceHandle second = metric.scope("s1");
+        assertSame(first, second);
+    }
+
+    @Test
+    void updateMarksMeterWithLongValue() {
+        metric.scope("s1").update(5L);
+        assertEquals(5, meter.getCount());
+    }
+
+    @Test
+    void updateAcceptsIntegerValues() {
+        metric.scope("s1").update(3);
+        assertEquals(3, meter.getCount());
+    }
+
+    @Test
+    void updateThrowsOnNonNumericValue() {
+        ScopedReducedMetric.ReduceHandle handle = metric.scope("s1");
+        assertThrows(IllegalArgumentException.class, () -> handle.update("not a number"));
+    }
+}
diff --git a/external/aws/src/main/java/org/apache/stormcrawler/aws/bolt/CloudSearchIndexerBolt.java b/external/aws/src/main/java/org/apache/stormcrawler/aws/bolt/CloudSearchIndexerBolt.java
index 2491f516..24f6bac6 100644
--- a/external/aws/src/main/java/org/apache/stormcrawler/aws/bolt/CloudSearchIndexerBolt.java
+++ b/external/aws/src/main/java/org/apache/stormcrawler/aws/bolt/CloudSearchIndexerBolt.java
@@ -52,7 +52,6 @@ import java.util.Map.Entry;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.storm.Config;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
@@ -60,6 +59,8 @@ import org.apache.storm.tuple.Values;
 import org.apache.storm.utils.TupleUtils;
 import org.apache.stormcrawler.Metadata;
 import org.apache.stormcrawler.indexing.AbstractIndexerBolt;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.util.ConfUtils;
 import org.slf4j.Logger;
@@ -91,7 +92,7 @@ public class CloudSearchIndexerBolt extends AbstractIndexerBolt {
 
     private OutputCollector _collector;
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private Map<String, String> csfields = new HashMap<>();
 
@@ -105,8 +106,7 @@ public class CloudSearchIndexerBolt extends AbstractIndexerBolt {
         super.prepare(conf, context, collector);
         _collector = collector;
 
-        this.eventCounter =
-                context.registerMetric("CloudSearchIndexer", new MultiCountMetric(), 10);
+        this.eventCounter = CrawlerMetrics.registerCounter(context, conf, "CloudSearchIndexer", 10);
 
         maxTimeBuffered = ConfUtils.getInt(conf, CloudSearchConstants.MAX_TIME_BUFFERED, 10);
 
diff --git a/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/AbstractS3CacheBolt.java b/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/AbstractS3CacheBolt.java
index 94ab205e..351b3567 100644
--- a/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/AbstractS3CacheBolt.java
+++ b/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/AbstractS3CacheBolt.java
@@ -25,12 +25,12 @@ import com.amazonaws.regions.RegionUtils;
 import com.amazonaws.services.s3.AmazonS3Client;
 import java.util.Map;
 import org.apache.commons.lang3.StringUtils;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
 import org.apache.storm.topology.base.BaseRichBolt;
 import org.apache.storm.tuple.Fields;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.util.ConfUtils;
 
 public abstract class AbstractS3CacheBolt extends BaseRichBolt {
@@ -45,7 +45,7 @@ public abstract class AbstractS3CacheBolt extends BaseRichBolt {
     public static final String INCACHE = S3_PREFIX + "inCache";
 
     protected OutputCollector _collector;
-    protected MultiCountMetric eventCounter;
+    protected ScopedCounter eventCounter;
 
     protected AmazonS3Client client;
 
diff --git a/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3CacheChecker.java b/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3CacheChecker.java
index 52bc447f..c560dc00 100644
--- a/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3CacheChecker.java
+++ b/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3CacheChecker.java
@@ -23,7 +23,6 @@ import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.util.Map;
 import org.apache.commons.io.IOUtils;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -31,6 +30,7 @@ import org.apache.storm.tuple.Fields;
 import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
 import org.apache.stormcrawler.util.ConfUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -56,7 +56,7 @@ public class S3CacheChecker extends AbstractS3CacheBolt {
             String message = "Bucket " + bucketName + " does not exist";
             throw new RuntimeException(message);
         }
-        this.eventCounter = context.registerMetric("s3cache_counter", new MultiCountMetric(), 10);
+        this.eventCounter = CrawlerMetrics.registerCounter(context, conf, "s3cache_counter", 10);
     }
 
     @Override
diff --git a/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3Cacher.java b/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3Cacher.java
index d8b0a271..ce120394 100644
--- a/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3Cacher.java
+++ b/external/aws/src/main/java/org/apache/stormcrawler/aws/s3/S3Cacher.java
@@ -25,12 +25,12 @@ import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.util.Map;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
 import org.apache.stormcrawler.util.ConfUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -61,8 +61,8 @@ public abstract class S3Cacher extends AbstractS3CacheBolt {
             throw new RuntimeException(message);
         }
         this.eventCounter =
-                context.registerMetric(
-                        getMetricPrefix() + "s3cache_counter", new MultiCountMetric(), 10);
+                CrawlerMetrics.registerCounter(
+                        context, conf, getMetricPrefix() + "s3cache_counter", 10);
     }
 
     @Override
diff --git a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/DeletionBolt.java b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/DeletionBolt.java
index c67b9095..f0fdec9e 100644
--- a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/DeletionBolt.java
+++ b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/DeletionBolt.java
@@ -36,6 +36,7 @@ import org.apache.storm.topology.OutputFieldsDeclarer;
 import org.apache.storm.topology.base.BaseRichBolt;
 import org.apache.storm.tuple.Tuple;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
 import org.apache.stormcrawler.opensearch.BulkItemResponseToFailedFlag;
 import org.apache.stormcrawler.opensearch.OpenSearchConnection;
 import org.apache.stormcrawler.util.ConfUtils;
@@ -103,7 +104,7 @@ public class DeletionBolt extends BaseRichBolt
                         .removalListener(this)
                         .build();
 
-        context.registerMetric("waitAck", () -> waitAck.estimatedSize(), 10);
+        CrawlerMetrics.registerGauge(context, conf, "waitAck", waitAck::estimatedSize, 10);
     }
 
     public void onRemoval(
diff --git a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/IndexerBolt.java b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/IndexerBolt.java
index 04de31ca..39b8bf05 100644
--- a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/IndexerBolt.java
+++ b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/bolt/IndexerBolt.java
@@ -37,8 +37,6 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
-import org.apache.storm.metric.api.MultiCountMetric;
-import org.apache.storm.metric.api.MultiReducedMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
@@ -46,12 +44,14 @@ import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
 import org.apache.stormcrawler.indexing.AbstractIndexerBolt;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
 import org.apache.stormcrawler.opensearch.BulkItemResponseToFailedFlag;
 import org.apache.stormcrawler.opensearch.IndexCreation;
 import org.apache.stormcrawler.opensearch.OpenSearchConnection;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.util.ConfUtils;
-import org.apache.stormcrawler.util.PerSecondReducer;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 import org.opensearch.action.DocWriteRequest;
@@ -93,11 +93,11 @@ public class IndexerBolt extends AbstractIndexerBolt
     // overwritten
     private boolean create = false;
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private OpenSearchConnection connection;
 
-    private MultiReducedMetric perSecMetrics;
+    private ScopedReducedMetric perSecMetrics;
 
     private Cache<String, List<Tuple>> waitAck;
 
@@ -130,13 +130,10 @@ public class IndexerBolt extends AbstractIndexerBolt
             throw new RuntimeException(e1);
         }
 
-        this.eventCounter = context.registerMetric("OpensearchIndexer", new MultiCountMetric(), 10);
+        this.eventCounter = CrawlerMetrics.registerCounter(context, conf, "OpensearchIndexer", 10);
 
         this.perSecMetrics =
-                context.registerMetric(
-                        "Indexer_average_persec",
-                        new MultiReducedMetric(new PerSecondReducer()),
-                        10);
+                CrawlerMetrics.registerPerSecMetric(context, conf, "Indexer_average_persec", 10);
 
         waitAck =
                 Caffeine.newBuilder()
@@ -144,7 +141,7 @@ public class IndexerBolt extends AbstractIndexerBolt
                         .removalListener(this)
                         .build();
 
-        context.registerMetric("waitAck", () -> waitAck.estimatedSize(), 10);
+        CrawlerMetrics.registerGauge(context, conf, "waitAck", waitAck::estimatedSize, 10);
 
         // use the default status schema if none has been specified
         try {
diff --git a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/metrics/MetricsReporter.java b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/metrics/MetricsReporter.java
new file mode 100644
index 00000000..3957ba6a
--- /dev/null
+++ b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/metrics/MetricsReporter.java
@@ -0,0 +1,221 @@
+/*
+ * 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.stormcrawler.opensearch.metrics;
+
+import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.codahale.metrics.Timer;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.concurrent.TimeUnit;
+import org.apache.storm.metrics2.reporters.ScheduledStormReporter;
+import org.apache.stormcrawler.opensearch.IndexCreation;
+import org.apache.stormcrawler.opensearch.OpenSearchConnection;
+import org.apache.stormcrawler.util.ConfUtils;
+import org.opensearch.action.index.IndexRequest;
+import org.opensearch.core.xcontent.XContentBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Storm V2 metrics reporter that writes metrics to an OpenSearch index with the same document
+ * structure as the V1 {@link MetricsConsumer}. This allows existing OpenSearch dashboards to work
+ * unchanged during migration from V1 to V2 metrics.
+ *
+ * <p>Configuration in storm.yaml:
+ *
+ * <pre>
+ *   storm.metrics.reporters:
+ *     - class: "org.apache.stormcrawler.opensearch.metrics.MetricsReporter"
+ *       report.period: 10
+ *       report.period.units: "SECONDS"
+ * </pre>
+ */
+public class MetricsReporter extends ScheduledStormReporter {
+
+    private static final Logger LOG = LoggerFactory.getLogger(MetricsReporter.class);
+
+    private static final String OSBoltType = "metrics";
+
+    private static final String OSMetricsIndexNameParamName =
+            "opensearch." + OSBoltType + ".index.name";
+
+    private static final String DATE_FORMAT_KEY = "opensearch.metrics.date.format";
+
+    @Override
+    public void prepare(
+            MetricRegistry metricsRegistry,
+            Map<String, Object> topoConf,
+            Map<String, Object> reporterConf) {
+
+        String indexName = ConfUtils.getString(topoConf, OSMetricsIndexNameParamName, "metrics");
+        String stormId = (String) topoConf.getOrDefault("storm.id", "unknown");
+
+        SimpleDateFormat dateFormat = null;
+        String dateFormatStr = ConfUtils.getString(topoConf, DATE_FORMAT_KEY, null);
+        if (dateFormatStr != null) {
+            dateFormat = new SimpleDateFormat(dateFormatStr, Locale.ROOT);
+        }
+
+        OpenSearchConnection connection;
+        try {
+            connection = OpenSearchConnection.getConnection(topoConf, OSBoltType);
+        } catch (Exception e) {
+            LOG.error("Can't connect to OpenSearch", e);
+            throw new RuntimeException(e);
+        }
+
+        try {
+            IndexCreation.checkOrCreateIndexTemplate(connection.getClient(), OSBoltType, LOG);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        TimeUnit reportPeriodUnit = getReportPeriodUnit(reporterConf);
+        long reportPeriod = getReportPeriod(reporterConf);
+
+        reporter =
+                new OpenSearchScheduledReporter(
+                        metricsRegistry, indexName, stormId, dateFormat, connection);
+
+        reporter.start(reportPeriod, reportPeriodUnit);
+    }
+
+    private ScheduledReporter reporter;
+
+    @Override
+    public void start() {
+        // already started in prepare()
+    }
+
+    @Override
+    public void stop() {
+        if (reporter != null) {
+            reporter.stop();
+        }
+    }
+
+    /**
+     * Inner ScheduledReporter that writes Codahale metrics to OpenSearch in the same format as the
+     * V1 MetricsConsumer.
+     */
+    private static class OpenSearchScheduledReporter extends ScheduledReporter {
+
+        private final String indexName;
+        private final String stormId;
+        private final SimpleDateFormat dateFormat;
+        private final OpenSearchConnection connection;
+
+        OpenSearchScheduledReporter(
+                MetricRegistry registry,
+                String indexName,
+                String stormId,
+                SimpleDateFormat dateFormat,
+                OpenSearchConnection connection) {
+            super(
+                    registry,
+                    "opensearch-metrics-reporter",
+                    MetricFilter.ALL,
+                    TimeUnit.SECONDS,
+                    TimeUnit.MILLISECONDS);
+            this.indexName = indexName;
+            this.stormId = stormId;
+            this.dateFormat = dateFormat;
+            this.connection = connection;
+        }
+
+        @Override
+        @SuppressWarnings("rawtypes")
+        public void report(
+                SortedMap<String, Gauge> gauges,
+                SortedMap<String, Counter> counters,
+                SortedMap<String, Histogram> histograms,
+                SortedMap<String, Meter> meters,
+                SortedMap<String, Timer> timers) {
+
+            Date now = new Date();
+
+            for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
+                Object value = entry.getValue().getValue();
+                if (value instanceof Number) {
+                    indexDataPoint(now, entry.getKey(), ((Number) value).doubleValue());
+                } else if (value instanceof Map) {
+                    for (Map.Entry<?, ?> mapEntry : ((Map<?, ?>) value).entrySet()) {
+                        if (mapEntry.getValue() instanceof Number) {
+                            indexDataPoint(
+                                    now,
+                                    entry.getKey() + "." + mapEntry.getKey(),
+                                    ((Number) mapEntry.getValue()).doubleValue());
+                        }
+                    }
+                }
+            }
+
+            for (Map.Entry<String, Counter> entry : counters.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getCount());
+            }
+
+            for (Map.Entry<String, Histogram> entry : histograms.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getSnapshot().getMean());
+            }
+
+            for (Map.Entry<String, Meter> entry : meters.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getOneMinuteRate());
+            }
+
+            for (Map.Entry<String, Timer> entry : timers.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getSnapshot().getMean());
+            }
+        }
+
+        private String getIndexName(Date timestamp) {
+            if (dateFormat == null) {
+                return indexName;
+            }
+            return indexName + "-" + dateFormat.format(timestamp);
+        }
+
+        private void indexDataPoint(Date timestamp, String name, double value) {
+            try {
+                XContentBuilder builder = jsonBuilder().startObject();
+                builder.field("stormId", stormId);
+                builder.field("name", name);
+                builder.field("value", value);
+                builder.field("timestamp", timestamp);
+                builder.endObject();
+
+                IndexRequest indexRequest =
+                        new IndexRequest(getIndexName(timestamp)).source(builder);
+                connection.addToProcessor(indexRequest);
+            } catch (Exception e) {
+                LOG.error("Problem when building request for OpenSearch", e);
+            }
+        }
+    }
+}
diff --git a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/metrics/StatusMetricsBolt.java b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/metrics/StatusMetricsBolt.java
index 07f09dda..0b71e101 100644
--- a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/metrics/StatusMetricsBolt.java
+++ b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/metrics/StatusMetricsBolt.java
@@ -26,6 +26,7 @@ import org.apache.storm.topology.OutputFieldsDeclarer;
 import org.apache.storm.topology.base.BaseRichBolt;
 import org.apache.storm.tuple.Tuple;
 import org.apache.storm.utils.TupleUtils;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
 import org.apache.stormcrawler.opensearch.Constants;
 import org.apache.stormcrawler.opensearch.OpenSearchConnection;
 import org.apache.stormcrawler.util.ConfUtils;
@@ -106,12 +107,8 @@ public class StatusMetricsBolt extends BaseRichBolt {
             throw new RuntimeException(e1);
         }
 
-        context.registerMetric(
-                "status.count",
-                () -> {
-                    return latestStatusCounts;
-                },
-                freqStats);
+        CrawlerMetrics.registerGauge(
+                context, stormConf, "status.count", () -> latestStatusCounts, freqStats);
 
         listeners = new StatusActionListener[6];
 
diff --git a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/AggregationSpout.java b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/AggregationSpout.java
index 47761c2e..79936b36 100644
--- a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/AggregationSpout.java
+++ b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/AggregationSpout.java
@@ -301,7 +301,7 @@ public class AggregationSpout extends AbstractSpout implements ActionListener<Se
                 alreadyprocessed,
                 ((float) timeTaken / numhits));
 
-        queryTimes.addMeasurement(timeTaken);
+        queryTimes.accept(timeTaken);
         eventCounter.scope("already_being_processed").incrBy(alreadyprocessed);
         eventCounter.scope("OpenSearch_queries").incrBy(1);
         eventCounter.scope("OpenSearch_docs").incrBy(numhits);
diff --git a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/StatusUpdaterBolt.java b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/StatusUpdaterBolt.java
index bd178f7d..df1598f0 100644
--- a/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/StatusUpdaterBolt.java
+++ b/external/opensearch/src/main/java/org/apache/stormcrawler/opensearch/persistence/StatusUpdaterBolt.java
@@ -35,12 +35,13 @@ import java.util.Set;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
-import org.apache.storm.metric.api.MultiCountMetric;
-import org.apache.storm.metric.api.MultiReducedMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
+import org.apache.stormcrawler.metrics.ScopedReducedMetric;
 import org.apache.stormcrawler.opensearch.BulkItemResponseToFailedFlag;
 import org.apache.stormcrawler.opensearch.Constants;
 import org.apache.stormcrawler.opensearch.IndexCreation;
@@ -48,7 +49,6 @@ import org.apache.stormcrawler.opensearch.OpenSearchConnection;
 import org.apache.stormcrawler.persistence.AbstractStatusUpdaterBolt;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.util.ConfUtils;
-import org.apache.stormcrawler.util.PerSecondReducer;
 import org.apache.stormcrawler.util.URLPartitioner;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
@@ -100,9 +100,9 @@ public class StatusUpdaterBolt extends AbstractStatusUpdaterBolt
     // Be fair due to cache timeout
     private final ReentrantLock waitAckLock = new ReentrantLock(true);
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
-    private MultiReducedMetric receivedPerSecMetrics;
+    private ScopedReducedMetric receivedPerSecMetrics;
 
     public StatusUpdaterBolt() {
         super();
@@ -174,18 +174,17 @@ public class StatusUpdaterBolt extends AbstractStatusUpdaterBolt
         int metrics_time_bucket_secs = 30;
 
         // create gauge for waitAck
-        context.registerMetric("waitAck", () -> waitAck.estimatedSize(), metrics_time_bucket_secs);
+        CrawlerMetrics.registerGauge(
+                context, stormConf, "waitAck", waitAck::estimatedSize, metrics_time_bucket_secs);
 
         // benchmarking - average number of items received back by Elastic per second
         this.receivedPerSecMetrics =
-                context.registerMetric(
-                        "average_persec",
-                        new MultiReducedMetric(new PerSecondReducer()),
-                        metrics_time_bucket_secs);
+                CrawlerMetrics.registerPerSecMetric(
+                        context, stormConf, "average_persec", metrics_time_bucket_secs);
 
         this.eventCounter =
-                context.registerMetric(
-                        "counters", new MultiCountMetric(), metrics_time_bucket_secs);
+                CrawlerMetrics.registerCounter(
+                        context, stormConf, "counters", metrics_time_bucket_secs);
 
         try {
             connection = OpenSearchConnection.getConnection(stormConf, OSBoltType, this);
diff --git a/external/opensearch/src/test/java/org/apache/stormcrawler/opensearch/metrics/MetricsReporterTest.java b/external/opensearch/src/test/java/org/apache/stormcrawler/opensearch/metrics/MetricsReporterTest.java
new file mode 100644
index 00000000..8c0c9413
--- /dev/null
+++ b/external/opensearch/src/test/java/org/apache/stormcrawler/opensearch/metrics/MetricsReporterTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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.stormcrawler.opensearch.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.MetricRegistry;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.stormcrawler.opensearch.bolt.AbstractOpenSearchTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+class MetricsReporterTest extends AbstractOpenSearchTest {
+
+    @Test
+    @Timeout(60)
+    void prepareAndReportMetrics() {
+        MetricRegistry registry = new MetricRegistry();
+        Counter counter = registry.counter("test.counter");
+        counter.inc(42);
+
+        Map<String, Object> topoConf = new HashMap<>();
+        topoConf.put(
+                "opensearch.metrics.addresses",
+                opensearchContainer.getHost() + ":" + opensearchContainer.getFirstMappedPort());
+
+        Map<String, Object> reporterConf = new HashMap<>();
+        reporterConf.put("report.period", 60L);
+        reporterConf.put("report.period.units", "SECONDS");
+
+        MetricsReporter reporter = new MetricsReporter();
+        assertDoesNotThrow(() -> reporter.prepare(registry, topoConf, reporterConf));
+        assertNotNull(reporter);
+        reporter.stop();
+    }
+}
diff --git a/external/solr/src/main/java/org/apache/stormcrawler/solr/bolt/IndexerBolt.java b/external/solr/src/main/java/org/apache/stormcrawler/solr/bolt/IndexerBolt.java
index 2b10dc83..c14854c7 100644
--- a/external/solr/src/main/java/org/apache/stormcrawler/solr/bolt/IndexerBolt.java
+++ b/external/solr/src/main/java/org/apache/stormcrawler/solr/bolt/IndexerBolt.java
@@ -22,13 +22,14 @@ import static org.apache.stormcrawler.Constants.StatusStreamName;
 import java.util.Map;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.solr.common.SolrInputDocument;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Metadata;
 import org.apache.stormcrawler.indexing.AbstractIndexerBolt;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.solr.SolrConnection;
 import org.slf4j.Logger;
@@ -43,7 +44,7 @@ public class IndexerBolt extends AbstractIndexerBolt {
 
     private OutputCollector _collector;
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private SolrConnection connection;
 
@@ -61,7 +62,7 @@ public class IndexerBolt extends AbstractIndexerBolt {
             throw new RuntimeException(e);
         }
 
-        this.eventCounter = context.registerMetric("SolrIndexerBolt", new MultiCountMetric(), 10);
+        this.eventCounter = CrawlerMetrics.registerCounter(context, conf, "SolrIndexerBolt", 10);
     }
 
     @Override
diff --git a/external/solr/src/main/java/org/apache/stormcrawler/solr/metrics/MetricsReporter.java b/external/solr/src/main/java/org/apache/stormcrawler/solr/metrics/MetricsReporter.java
new file mode 100644
index 00000000..f8cfd4b0
--- /dev/null
+++ b/external/solr/src/main/java/org/apache/stormcrawler/solr/metrics/MetricsReporter.java
@@ -0,0 +1,174 @@
+/*
+ * 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.stormcrawler.solr.metrics;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.codahale.metrics.Timer;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.concurrent.TimeUnit;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.storm.metrics2.reporters.ScheduledStormReporter;
+import org.apache.stormcrawler.solr.SolrConnection;
+import org.apache.stormcrawler.util.ConfUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Storm V2 metrics reporter that writes metrics to Solr with the same document structure as the V1
+ * {@link MetricsConsumer}.
+ *
+ * <p>Configuration in storm.yaml:
+ *
+ * <pre>
+ *   storm.metrics.reporters:
+ *     - class: "org.apache.stormcrawler.solr.metrics.MetricsReporter"
+ *       report.period: 10
+ *       report.period.units: "SECONDS"
+ * </pre>
+ */
+public class MetricsReporter extends ScheduledStormReporter {
+
+    private static final Logger LOG = LoggerFactory.getLogger(MetricsReporter.class);
+
+    private static final String BOLT_TYPE = "metrics";
+
+    private static final String SolrTTLParamName = "solr.metrics.ttl";
+    private static final String SolrTTLFieldParamName = "solr.metrics.ttl.field";
+
+    private ScheduledReporter reporter;
+
+    @Override
+    public void prepare(
+            MetricRegistry metricsRegistry,
+            Map<String, Object> topoConf,
+            Map<String, Object> reporterConf) {
+
+        String ttlField = ConfUtils.getString(topoConf, SolrTTLFieldParamName, "__ttl__");
+        String ttl = ConfUtils.getString(topoConf, SolrTTLParamName, null);
+
+        SolrConnection connection;
+        try {
+            connection = SolrConnection.getConnection(topoConf, BOLT_TYPE);
+        } catch (Exception e) {
+            LOG.error("Can't connect to Solr: {}", e);
+            throw new RuntimeException(e);
+        }
+
+        TimeUnit reportPeriodUnit = getReportPeriodUnit(reporterConf);
+        long reportPeriod = getReportPeriod(reporterConf);
+
+        reporter = new SolrScheduledReporter(metricsRegistry, connection, ttlField, ttl);
+        reporter.start(reportPeriod, reportPeriodUnit);
+    }
+
+    @Override
+    public void start() {
+        // already started in prepare()
+    }
+
+    @Override
+    public void stop() {
+        if (reporter != null) {
+            reporter.stop();
+        }
+    }
+
+    private static class SolrScheduledReporter extends ScheduledReporter {
+
+        private final SolrConnection connection;
+        private final String ttlField;
+        private final String ttl;
+        private final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT);
+
+        SolrScheduledReporter(
+                MetricRegistry registry, SolrConnection connection, String ttlField, String ttl) {
+            super(
+                    registry,
+                    "solr-metrics-reporter",
+                    MetricFilter.ALL,
+                    TimeUnit.SECONDS,
+                    TimeUnit.MILLISECONDS);
+            this.connection = connection;
+            this.ttlField = ttlField;
+            this.ttl = ttl;
+        }
+
+        @Override
+        @SuppressWarnings("rawtypes")
+        public void report(
+                SortedMap<String, Gauge> gauges,
+                SortedMap<String, Counter> counters,
+                SortedMap<String, Histogram> histograms,
+                SortedMap<String, Meter> meters,
+                SortedMap<String, Timer> timers) {
+
+            Date now = new Date();
+
+            for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
+                Object value = entry.getValue().getValue();
+                if (value instanceof Number) {
+                    indexDataPoint(now, entry.getKey(), ((Number) value).doubleValue());
+                }
+            }
+
+            for (Map.Entry<String, Counter> entry : counters.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getCount());
+            }
+
+            for (Map.Entry<String, Histogram> entry : histograms.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getSnapshot().getMean());
+            }
+
+            for (Map.Entry<String, Meter> entry : meters.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getOneMinuteRate());
+            }
+
+            for (Map.Entry<String, Timer> entry : timers.entrySet()) {
+                indexDataPoint(now, entry.getKey(), entry.getValue().getSnapshot().getMean());
+            }
+        }
+
+        private void indexDataPoint(Date timestamp, String name, double value) {
+            try {
+                SolrInputDocument doc = new SolrInputDocument();
+                doc.addField("name", name);
+                doc.addField("value", value);
+                doc.addField("timestamp", df.format(timestamp));
+
+                if (ttl != null) {
+                    doc.addField(ttlField, ttl);
+                }
+
+                connection.addAsync(doc);
+            } catch (Exception e) {
+                LOG.error("Problem building a document to Solr", e);
+            }
+        }
+    }
+}
diff --git a/external/solr/src/main/java/org/apache/stormcrawler/solr/persistence/SolrSpout.java b/external/solr/src/main/java/org/apache/stormcrawler/solr/persistence/SolrSpout.java
index 10b437c5..813c401c 100644
--- a/external/solr/src/main/java/org/apache/stormcrawler/solr/persistence/SolrSpout.java
+++ b/external/solr/src/main/java/org/apache/stormcrawler/solr/persistence/SolrSpout.java
@@ -202,7 +202,7 @@ public class SolrSpout extends AbstractQueryingSpout {
 
         markQueryReceivedNow();
 
-        queryTimes.addMeasurement(timeTaken);
+        queryTimes.accept(timeTaken);
 
         SolrDocumentList docs = new SolrDocumentList();
 
diff --git a/external/sql/src/main/java/org/apache/stormcrawler/sql/IndexerBolt.java b/external/sql/src/main/java/org/apache/stormcrawler/sql/IndexerBolt.java
index 2ae45ae7..53c320ad 100644
--- a/external/sql/src/main/java/org/apache/stormcrawler/sql/IndexerBolt.java
+++ b/external/sql/src/main/java/org/apache/stormcrawler/sql/IndexerBolt.java
@@ -28,13 +28,14 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Metadata;
 import org.apache.stormcrawler.indexing.AbstractIndexerBolt;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.util.ConfUtils;
 import org.slf4j.Logger;
@@ -49,7 +50,7 @@ public class IndexerBolt extends AbstractIndexerBolt {
 
     private OutputCollector collector;
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private Connection connection;
 
@@ -65,7 +66,7 @@ public class IndexerBolt extends AbstractIndexerBolt {
         super.prepare(conf, context, collector);
         this.collector = collector;
 
-        this.eventCounter = context.registerMetric("SQLIndexer", new MultiCountMetric(), 10);
+        this.eventCounter = CrawlerMetrics.registerCounter(context, conf, "SQLIndexer", 10);
 
         this.tableName = ConfUtils.getString(conf, SQL_INDEX_TABLE_PARAM_NAME);
 
diff --git a/external/sql/src/main/java/org/apache/stormcrawler/sql/SQLSpout.java b/external/sql/src/main/java/org/apache/stormcrawler/sql/SQLSpout.java
index 8c69c511..0129cf3d 100644
--- a/external/sql/src/main/java/org/apache/stormcrawler/sql/SQLSpout.java
+++ b/external/sql/src/main/java/org/apache/stormcrawler/sql/SQLSpout.java
@@ -221,7 +221,7 @@ public class SQLSpout extends AbstractQueryingSpout {
 
     private long recordQueryTiming(long timeStartQuery) {
         long timeTaken = System.currentTimeMillis() - timeStartQuery;
-        queryTimes.addMeasurement(timeTaken);
+        queryTimes.accept(timeTaken);
         return timeTaken;
     }
 
diff --git a/external/sql/src/main/java/org/apache/stormcrawler/sql/StatusUpdaterBolt.java b/external/sql/src/main/java/org/apache/stormcrawler/sql/StatusUpdaterBolt.java
index e2b0e91e..80642dba 100644
--- a/external/sql/src/main/java/org/apache/stormcrawler/sql/StatusUpdaterBolt.java
+++ b/external/sql/src/main/java/org/apache/stormcrawler/sql/StatusUpdaterBolt.java
@@ -32,11 +32,12 @@ import java.util.Optional;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.persistence.AbstractStatusUpdaterBolt;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.util.ConfUtils;
@@ -53,7 +54,7 @@ public class StatusUpdaterBolt extends AbstractStatusUpdaterBolt {
 
     private static final Timestamp NEVER = Timestamp.valueOf("3000-01-01 00:00:00");
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private Connection connection;
 
@@ -87,7 +88,7 @@ public class StatusUpdaterBolt extends AbstractStatusUpdaterBolt {
         partitioner = new URLPartitioner();
         partitioner.configure(stormConf);
 
-        this.eventCounter = context.registerMetric("counter", new MultiCountMetric(), 10);
+        this.eventCounter = CrawlerMetrics.registerCounter(context, stormConf, "counter", 10);
 
         final String tableName =
                 ConfUtils.getString(stormConf, Constants.SQL_STATUS_TABLE_PARAM_NAME, "urls");
diff --git a/external/sql/src/main/java/org/apache/stormcrawler/sql/metrics/MetricsReporter.java b/external/sql/src/main/java/org/apache/stormcrawler/sql/metrics/MetricsReporter.java
new file mode 100644
index 00000000..c8e6240c
--- /dev/null
+++ b/external/sql/src/main/java/org/apache/stormcrawler/sql/metrics/MetricsReporter.java
@@ -0,0 +1,180 @@
+/*
+ * 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.stormcrawler.sql.metrics;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.codahale.metrics.Timer;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.concurrent.TimeUnit;
+import org.apache.storm.metrics2.reporters.ScheduledStormReporter;
+import org.apache.stormcrawler.sql.Constants;
+import org.apache.stormcrawler.sql.SQLUtil;
+import org.apache.stormcrawler.util.ConfUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Storm V2 metrics reporter that writes metrics to a SQL table with the same schema as the V1
+ * {@link MetricsConsumer}.
+ *
+ * <p>Configuration in storm.yaml:
+ *
+ * <pre>
+ *   storm.metrics.reporters:
+ *     - class: "org.apache.stormcrawler.sql.metrics.MetricsReporter"
+ *       report.period: 10
+ *       report.period.units: "SECONDS"
+ * </pre>
+ */
+public class MetricsReporter extends ScheduledStormReporter {
+
+    private static final Logger LOG = LoggerFactory.getLogger(MetricsReporter.class);
+
+    private ScheduledReporter reporter;
+
+    @Override
+    public void prepare(
+            MetricRegistry metricsRegistry,
+            Map<String, Object> topoConf,
+            Map<String, Object> reporterConf) {
+
+        String tableName =
+                ConfUtils.getString(topoConf, Constants.SQL_METRICS_TABLE_PARAM_NAME, "metrics");
+
+        Connection connection;
+        try {
+            connection = SQLUtil.getConnection(topoConf);
+        } catch (SQLException ex) {
+            LOG.error(ex.getMessage(), ex);
+            throw new RuntimeException(ex);
+        }
+
+        String query =
+                "INSERT INTO " + tableName + " (name, value, timestamp)" + " values (?, ?, ?)";
+
+        TimeUnit reportPeriodUnit = getReportPeriodUnit(reporterConf);
+        long reportPeriod = getReportPeriod(reporterConf);
+
+        reporter = new SQLScheduledReporter(metricsRegistry, connection, query);
+        reporter.start(reportPeriod, reportPeriodUnit);
+    }
+
+    @Override
+    public void start() {
+        // already started in prepare()
+    }
+
+    @Override
+    public void stop() {
+        if (reporter != null) {
+            reporter.stop();
+        }
+    }
+
+    private static class SQLScheduledReporter extends ScheduledReporter {
+
+        private final Connection connection;
+        private final String query;
+
+        SQLScheduledReporter(MetricRegistry registry, Connection connection, String query) {
+            super(
+                    registry,
+                    "sql-metrics-reporter",
+                    MetricFilter.ALL,
+                    TimeUnit.SECONDS,
+                    TimeUnit.MILLISECONDS);
+            this.connection = connection;
+            this.query = query;
+        }
+
+        @Override
+        @SuppressWarnings("rawtypes")
+        public void report(
+                SortedMap<String, Gauge> gauges,
+                SortedMap<String, Counter> counters,
+                SortedMap<String, Histogram> histograms,
+                SortedMap<String, Meter> meters,
+                SortedMap<String, Timer> timers) {
+
+            Timestamp now = Timestamp.from(Instant.now());
+
+            try {
+                PreparedStatement preparedStmt = connection.prepareStatement(query);
+
+                for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
+                    Object value = entry.getValue().getValue();
+                    if (value instanceof Number) {
+                        addDataPoint(
+                                preparedStmt, now, entry.getKey(), ((Number) value).doubleValue());
+                    }
+                }
+
+                for (Map.Entry<String, Counter> entry : counters.entrySet()) {
+                    addDataPoint(preparedStmt, now, entry.getKey(), entry.getValue().getCount());
+                }
+
+                for (Map.Entry<String, Histogram> entry : histograms.entrySet()) {
+                    addDataPoint(
+                            preparedStmt,
+                            now,
+                            entry.getKey(),
+                            entry.getValue().getSnapshot().getMean());
+                }
+
+                for (Map.Entry<String, Meter> entry : meters.entrySet()) {
+                    addDataPoint(
+                            preparedStmt, now, entry.getKey(), entry.getValue().getOneMinuteRate());
+                }
+
+                for (Map.Entry<String, Timer> entry : timers.entrySet()) {
+                    addDataPoint(
+                            preparedStmt,
+                            now,
+                            entry.getKey(),
+                            entry.getValue().getSnapshot().getMean());
+                }
+
+                preparedStmt.executeBatch();
+                preparedStmt.close();
+            } catch (SQLException ex) {
+                LOG.error(ex.getMessage(), ex);
+            }
+        }
+
+        private void addDataPoint(
+                PreparedStatement preparedStmt, Timestamp timestamp, String name, double value)
+                throws SQLException {
+            preparedStmt.setString(1, name);
+            preparedStmt.setDouble(2, value);
+            preparedStmt.setObject(3, timestamp);
+            preparedStmt.addBatch();
+        }
+    }
+}
diff --git a/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java b/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java
index 2586114a..46179df9 100644
--- a/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java
+++ b/external/tika/src/main/java/org/apache/stormcrawler/tika/ParserBolt.java
@@ -32,7 +32,6 @@ import java.util.regex.Pattern;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.html.dom.HTMLDocumentImpl;
 import org.apache.http.HttpHeaders;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -43,6 +42,8 @@ import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
 import org.apache.stormcrawler.filtering.URLFilters;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.parse.Outlink;
 import org.apache.stormcrawler.parse.ParseData;
 import org.apache.stormcrawler.parse.ParseFilter;
@@ -83,7 +84,7 @@ public class ParserBolt extends BaseRichBolt {
 
     private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(ParserBolt.class);
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     private boolean upperCaseElementNames = true;
     private Class<? extends HtmlMapper> htmlMapperClass = IdentityHtmlMapper.class;
@@ -144,7 +145,7 @@ public class ParserBolt extends BaseRichBolt {
         this.collector = collector;
 
         this.eventCounter =
-                context.registerMetric(this.getClass().getSimpleName(), new MultiCountMetric(), 10);
+                CrawlerMetrics.registerCounter(context, conf, this.getClass().getSimpleName(), 10);
 
         this.metadataTransfer = MetadataTransfer.getInstance(conf);
     }
diff --git a/external/urlfrontier/pom.xml b/external/urlfrontier/pom.xml
index c92d62ce..1acc5e78 100644
--- a/external/urlfrontier/pom.xml
+++ b/external/urlfrontier/pom.xml
@@ -40,7 +40,7 @@ under the License.
         <urlfrontier.version>2.5</urlfrontier.version>
         <jacoco.haltOnFailure>true</jacoco.haltOnFailure>
         <jacoco.classRatio>0.50</jacoco.classRatio>
-        <jacoco.instructionRatio>0.50</jacoco.instructionRatio>
+        <jacoco.instructionRatio>0.49</jacoco.instructionRatio>
         <jacoco.methodRatio>0.53</jacoco.methodRatio>
         <jacoco.branchRatio>0.42</jacoco.branchRatio>
         <jacoco.lineRatio>0.45</jacoco.lineRatio>
diff --git a/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java
index 87c8fa04..34ba3a4c 100644
--- a/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java
+++ b/external/urlfrontier/src/main/java/org/apache/stormcrawler/urlfrontier/StatusUpdaterBolt.java
@@ -56,11 +56,12 @@ import java.util.Optional;
 import java.util.concurrent.Semaphore;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.ReentrantLock;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.task.OutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.tuple.Tuple;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.persistence.AbstractStatusUpdaterBolt;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.util.ConfUtils;
@@ -93,7 +94,7 @@ public class StatusUpdaterBolt extends AbstractStatusUpdaterBolt
     // Faster ways of locking until n messages are processed
     private Semaphore inFlightSemaphore;
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     /** Globally set crawlID * */
     private String globalCrawlID;
@@ -119,7 +120,8 @@ public class StatusUpdaterBolt extends AbstractStatusUpdaterBolt
         throttleTimeMS = ConfUtils.getLong(stormConf, URLFRONTIER_THROTTLING_TIME_MS_KEY, 10);
 
         eventCounter =
-                context.registerMetric(this.getClass().getSimpleName(), new MultiCountMetric(), 30);
+                CrawlerMetrics.registerCounter(
+                        context, stormConf, this.getClass().getSimpleName(), 30);
 
         maxMessagesInFlight =
                 ConfUtils.getInt(
diff --git a/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCSpout.java b/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCSpout.java
index 7e654206..fa1ca11d 100644
--- a/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCSpout.java
+++ b/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCSpout.java
@@ -31,7 +31,6 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import org.apache.hadoop.conf.Configuration;
-import org.apache.storm.metric.api.MultiCountMetric;
 import org.apache.storm.spout.SpoutOutputCollector;
 import org.apache.storm.task.TopologyContext;
 import org.apache.storm.topology.OutputFieldsDeclarer;
@@ -39,6 +38,8 @@ import org.apache.storm.tuple.Fields;
 import org.apache.storm.tuple.Values;
 import org.apache.stormcrawler.Constants;
 import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.metrics.CrawlerMetrics;
+import org.apache.stormcrawler.metrics.ScopedCounter;
 import org.apache.stormcrawler.persistence.Status;
 import org.apache.stormcrawler.protocol.ProtocolResponse;
 import org.apache.stormcrawler.spout.FileSpout;
@@ -78,7 +79,7 @@ public class WARCSpout extends FileSpout {
     private WarcRequest precedingWarcRequest;
     private Optional<WarcRecord> record;
 
-    private MultiCountMetric eventCounter;
+    private ScopedCounter eventCounter;
 
     protected transient Configuration hdfsConfig;
 
@@ -385,8 +386,8 @@ public class WARCSpout extends FileSpout {
 
         int metricsTimeBucketSecs = ConfUtils.getInt(conf, "fetcher.metrics.time.bucket.secs", 10);
         eventCounter =
-                context.registerMetric(
-                        "warc_spout_counter", new MultiCountMetric(), metricsTimeBucketSecs);
+                CrawlerMetrics.registerCounter(
+                        context, conf, "warc_spout_counter", metricsTimeBucketSecs);
 
         hdfsConfig = new Configuration();