You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by lh...@apache.org on 2026/04/20 08:34:11 UTC

(pulsar) branch branch-4.0 updated: [fix][sec][branch-4.0] Upgrade to Jetty 12.1.8 to address several CVEs (#25534)

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

lhotari pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/pulsar.git


The following commit(s) were added to refs/heads/branch-4.0 by this push:
     new e428972aec0 [fix][sec][branch-4.0] Upgrade to Jetty 12.1.8 to address several CVEs (#25534)
e428972aec0 is described below

commit e428972aec0bcfd12fb0614bf89b53c7c6f71709
Author: Lari Hotari <lh...@users.noreply.github.com>
AuthorDate: Mon Apr 20 11:34:04 2026 +0300

    [fix][sec][branch-4.0] Upgrade to Jetty 12.1.8 to address several CVEs (#25534)
---
 conf/bookkeeper.conf                               |    2 +-
 conf/zookeeper.conf                                |    4 +-
 distribution/server/pom.xml                        |   43 +-
 distribution/server/src/assemble/LICENSE.bin.txt   |   72 +-
 distribution/server/src/assemble/bin.xml           |    2 +
 distribution/shell/src/assemble/LICENSE.bin.txt    |   21 +-
 .../bookkeeper-prometheus-metrics-provider/pom.xml |   85 ++
 .../bookkeeper/DataSketchesOpStatsLogger.java      |  222 ++++
 .../prometheus/bookkeeper/LongAdderCounter.java    |   91 ++
 .../bookkeeper/PrometheusMetricsProvider.java      |  253 +++++
 .../prometheus/bookkeeper/PrometheusServlet.java   |   60 +
 .../bookkeeper/PrometheusStatsLogger.java          |  100 ++
 .../bookkeeper/PrometheusTextFormat.java           |  240 ++++
 .../prometheus/bookkeeper/ScopeContext.java        |   39 +-
 .../metrics/prometheus/bookkeeper/SimpleGauge.java |   33 +-
 .../ThreadScopedDataSketchesStatsLogger.java       |  137 +++
 .../bookkeeper/ThreadScopedLongAdderCounter.java   |  111 ++
 .../prometheus/bookkeeper/package-info.java        |   31 +-
 .../bookkeeper/TestPrometheusFormatter.java        |  296 +++++
 .../bookkeeper/TestPrometheusMetricsProvider.java  |  166 +++
 jetty-upgrade/pom.xml                              |   39 +
 .../zookeeper-prometheus-metrics}/pom.xml          |   78 +-
 .../zookeeper/PrometheusMetricsProvider.java       |  632 +++++++++++
 .../metrics/prometheus/zookeeper/package-info.java |   31 +-
 .../prometheus/zookeeper/ExportJvmInfoTest.java    |   61 +
 .../PrometheusMetricsProviderConfigTest.java       |   66 ++
 .../zookeeper/PrometheusMetricsProviderTest.java   |  714 ++++++++++++
 jetty-upgrade/zookeeper-with-patched-admin/pom.xml |  173 +++
 .../apache/zookeeper/server/admin/AdminServer.java |   53 +
 .../zookeeper/server/admin/AdminServerFactory.java |   64 ++
 .../apache/zookeeper/server/admin/AuthRequest.java |   64 ++
 .../org/apache/zookeeper/server/admin/Command.java |   90 ++
 .../apache/zookeeper/server/admin/CommandBase.java |   83 ++
 .../zookeeper/server/admin/CommandOutputter.java   |   41 +-
 .../zookeeper/server/admin/CommandResponse.java    |  190 ++++
 .../apache/zookeeper/server/admin/Commands.java    | 1168 ++++++++++++++++++++
 .../zookeeper/server/admin/DummyAdminServer.java   |   27 +-
 .../apache/zookeeper/server/admin/GetCommand.java  |   30 +-
 .../zookeeper/server/admin/JettyAdminServer.java   |  397 +++++++
 .../zookeeper/server/admin/JsonOutputter.java      |   69 ++
 .../apache/zookeeper/server/admin/PostCommand.java |   30 +-
 .../zookeeper/server/admin/StreamOutputter.java    |   52 +
 .../zookeeper/server/admin/package-info.java       |   31 +-
 managed-ledger/pom.xml                             |   17 +-
 .../bookkeeper/mledger/impl/ManagedLedgerTest.java |    6 +-
 pom.xml                                            |  180 +--
 pulsar-broker-auth-athenz/pom.xml                  |    2 +-
 pulsar-broker-auth-oidc/pom.xml                    |    2 +-
 pulsar-broker-common/pom.xml                       |   25 +-
 .../apache/pulsar/broker/web/GzipHandlerUtil.java  |   11 +-
 .../pulsar/broker/web/JettyRequestLogFactory.java  |   58 +-
 .../web/plugin/servlet/AdditionalServlet.java      |   27 +-
 .../servlet/AdditionalServletWithClassLoader.java  |   16 +-
 .../jetty/metrics/JettyStatisticsCollector.java    |  111 ++
 .../apache/pulsar/jetty/metrics/package-info.java} |   28 +-
 .../pulsar/jetty/tls/JettySslContextFactory.java   |    4 +
 .../web/plugin/servlet/MockAdditionalServlet.java  |    3 +-
 .../jetty/tls/JettySslContextFactoryTest.java      |    6 +-
 pulsar-broker/pom.xml                              |   29 +-
 .../org/apache/pulsar/broker/PulsarService.java    |   87 +-
 .../prometheus/PulsarPrometheusMetricsServlet.java |    2 +-
 .../apache/pulsar/broker/web/ExceptionHandler.java |   37 +-
 .../org/apache/pulsar/broker/web/WebService.java   |   76 +-
 .../apache/pulsar/PrometheusMetricsTestUtil.java   |    2 +-
 .../pulsar/broker/BrokerAdditionalServletTest.java |   15 +-
 .../broker/admin/AdminApiGetLastMessageIdTest.java |    3 +-
 .../org/apache/pulsar/broker/admin/AdminTest.java  |   11 +-
 .../pulsar/broker/admin/MockServletContext.java    |  279 -----
 .../apache/pulsar/broker/admin/NamespacesTest.java |    5 +-
 .../pulsar/broker/admin/NamespacesV2Test.java      |    3 +-
 .../pulsar/broker/admin/PersistentTopicsTest.java  |    7 +-
 .../pulsar/broker/admin/ResourceGroupsTest.java    |    4 +-
 .../pulsar/broker/admin/TopicPoliciesTest.java     |   29 +-
 .../broker/intercept/BrokerInterceptorTest.java    |   26 +-
 .../broker/intercept/CounterBrokerInterceptor.java |    6 +-
 .../pulsar/broker/service/BrokerServiceTest.java   |    8 +-
 .../pulsar/broker/web/ExceptionHandlerTest.java    |   17 +-
 .../broker/web/WebServiceOriginalClientIPTest.java |   10 +-
 .../AdditionalServletWithClassLoaderTest.java      |   15 +-
 .../MockAdditionalServletWithClassLoader.java      |    3 +-
 .../pulsar/client/api/MockBrokerService.java       |   12 +-
 .../proxy/ClientSideEncryptionWssConsumer.java     |   21 +-
 .../proxy/ClientSideEncryptionWssProducer.java     |   24 +-
 .../websocket/proxy/ProxyAuthenticationTest.java   |   16 +-
 .../proxy/ProxyEncryptionPublishConsumeTest.java   |   16 +-
 .../websocket/proxy/ProxyIdleTimeoutTest.java      |    4 +-
 .../pulsar/websocket/proxy/ProxyPingTest.java      |    4 +-
 .../websocket/proxy/ProxyPublishConsumeTest.java   |  156 ++-
 .../proxy/ProxyPublishConsumeTlsTest.java          |   27 +-
 .../proxy/ProxyPublishConsumeWithoutZKTest.java    |    8 +-
 .../pulsar/websocket/proxy/ProxyRoleAuthTest.java  |   18 +-
 .../websocket/proxy/SimpleConsumerSocket.java      |   22 +-
 .../websocket/proxy/SimpleProducerSocket.java      |   14 +-
 .../proxy/v1/V1ProxyAuthenticationTest.java        |   16 +-
 pulsar-client-admin/pom.xml                        |    2 +-
 .../client/admin/internal/PulsarAdminImpl.java     |    6 +-
 .../admin/internal/http/AsyncHttpConnector.java    |    6 +-
 .../internal/http/AsyncHttpConnectorProvider.java  |   17 +-
 pulsar-client-auth-athenz/pom.xml                  |   41 +-
 pulsar-client-tools-test/pom.xml                   |    5 +
 pulsar-client-tools/pom.xml                        |    7 +-
 .../pulsar/client/cli/AbstractCmdConsume.java      |   14 +-
 .../org/apache/pulsar/client/cli/CmdConsume.java   |   21 +-
 .../org/apache/pulsar/client/cli/CmdProduce.java   |   36 +-
 .../java/org/apache/pulsar/client/cli/CmdRead.java |   20 +-
 .../org/apache/pulsar/client/impl/HttpClient.java  |   26 +-
 pulsar-common/pom.xml                              |    5 +
 pulsar-functions/utils/pom.xml                     |    2 +-
 pulsar-functions/worker/pom.xml                    |   18 +-
 .../pulsar/functions/worker/rest/WorkerServer.java |   66 +-
 pulsar-io/alluxio/pom.xml                          |    7 +
 pulsar-io/debezium/core/pom.xml                    |    4 +
 pulsar-io/hbase/pom.xml                            |    6 +-
 pulsar-io/http/pom.xml                             |    2 +-
 pulsar-io/jdbc/openmldb/pom.xml                    |    6 +-
 pulsar-io/kafka/pom.xml                            |    5 -
 pulsar-io/solr/pom.xml                             |    7 +-
 pulsar-metadata/pom.xml                            |    5 +-
 .../bookkeeper-storage/pom.xml                     |    7 +
 pulsar-proxy/pom.xml                               |   29 +-
 .../pulsar/proxy/server/AdminProxyHandler.java     |  177 ++-
 .../pulsar/proxy/server/ProxyServiceStarter.java   |   80 +-
 .../org/apache/pulsar/proxy/server/WebServer.java  |   71 +-
 .../server/AdminProxyHandlerKeystoreTLSTest.java   |    2 +-
 .../pulsar/proxy/server/AdminProxyHandlerTest.java |   52 +-
 .../proxy/server/AuthedAdminProxyHandlerTest.java  |    2 +-
 .../proxy/server/ProxyAdditionalServletTest.java   |    7 +-
 .../pulsar/proxy/server/ProxyIsAHttpProxyTest.java |   23 +-
 .../proxy/server/ProxyMultiBrokerBaseTest.java     |  128 +++
 .../proxy/server/ProxyOriginalClientIPTest.java    |    8 +-
 .../proxy/server/ProxyPackagesUploadTest.java      |    2 +-
 .../pulsar/proxy/server/ProxyRedirectTest.java     |   84 ++
 .../proxy/server/ProxyServiceStarterTest.java      |   47 +-
 .../proxy/server/ProxyServiceTlsStarterTest.java   |   41 +-
 .../SuperUserAuthedAdminProxyHandlerTest.java      |    2 +-
 .../server/UnauthedAdminProxyHandlerTest.java      |    2 +-
 pulsar-testclient/pom.xml                          |   11 +-
 .../proxy/socket/client/PerformanceClient.java     |    9 +-
 .../socket/client/SimpleTestProducerSocket.java    |   20 +-
 pulsar-websocket/pom.xml                           |   24 +-
 .../pulsar/websocket/AbstractWebSocketHandler.java |   17 +-
 .../apache/pulsar/websocket/ConsumerHandler.java   |   29 +-
 .../websocket/MultiTopicConsumerHandler.java       |    4 +-
 .../apache/pulsar/websocket/ProducerHandler.java   |   15 +-
 .../org/apache/pulsar/websocket/ReaderHandler.java |   19 +-
 .../pulsar/websocket/WebSocketConsumerServlet.java |   20 +-
 .../WebSocketHttpServletRequestWrapper.java        |    3 +-
 .../WebSocketMultiTopicConsumerServlet.java        |   21 +-
 .../pulsar/websocket/WebSocketProducerServlet.java |   19 +-
 .../pulsar/websocket/WebSocketReaderServlet.java   |   19 +-
 .../apache/pulsar/websocket/WebSocketService.java  |    4 +-
 .../pulsar/websocket/service/ProxyServer.java      |   69 +-
 .../pulsar/websocket/stats/ProxyTopicStat.java     |    6 +-
 .../websocket/AbstractWebSocketHandlerTest.java    |   27 +-
 .../pulsar/websocket/PingPongSupportTest.java      |   58 +-
 .../pulsar/websocket/ProducerHandlerTest.java      |    4 +-
 .../apache/pulsar/websocket/ReaderHandlerTest.java |   14 +-
 .../WebSocketHttpServletRequestWrapperTest.java    |    8 +-
 testmocks/pom.xml                                  |    5 +-
 .../plugins/RandomAdditionalServlet.java           |    5 +-
 tests/integration/pom.xml                          |    5 +
 .../integration/websocket/WebSocketTestSuite.java  |   18 +-
 tiered-storage/file-system/pom.xml                 |  454 ++++----
 163 files changed, 7835 insertions(+), 1889 deletions(-)

diff --git a/conf/bookkeeper.conf b/conf/bookkeeper.conf
index 562e37eec33..d680e411353 100644
--- a/conf/bookkeeper.conf
+++ b/conf/bookkeeper.conf
@@ -595,7 +595,7 @@ compactionRateByBytes=1000000
 # enableStatistics=true
 
 # Stats Provider Class (if statistics are enabled)
-statsProviderClass=org.apache.bookkeeper.stats.prometheus.PrometheusMetricsProvider
+statsProviderClass=org.apache.pulsar.metrics.prometheus.bookkeeper.PrometheusMetricsProvider
 
 # Default port for Prometheus metrics exporter
 prometheusStatsHttpPort=8000
diff --git a/conf/zookeeper.conf b/conf/zookeeper.conf
index db85e688e5c..c497ff77008 100644
--- a/conf/zookeeper.conf
+++ b/conf/zookeeper.conf
@@ -76,6 +76,6 @@ portUnification=false
 ## Metrics Providers
 #
 # https://prometheus.io Metrics Exporter
-metricsProvider.className=org.apache.zookeeper.metrics.prometheus.PrometheusMetricsProvider
+metricsProvider.className=org.apache.pulsar.metrics.prometheus.zookeeper.PrometheusMetricsProvider
 metricsProvider.httpPort=8000
-metricsProvider.exportJvmInfo=true
+metricsProvider.exportJvmInfo=true
\ No newline at end of file
diff --git a/distribution/server/pom.xml b/distribution/server/pom.xml
index f2caf97cc95..647c55a1866 100644
--- a/distribution/server/pom.xml
+++ b/distribution/server/pom.xml
@@ -82,29 +82,21 @@
     </dependency>
 
     <dependency>
-      <groupId>jline</groupId>
-      <artifactId>jline</artifactId>
-      <version>${jline.version}</version>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>pulsar-bookkeeper-prometheus-metrics-provider</artifactId>
+      <version>${project.version}</version>
     </dependency>
 
     <dependency>
-      <groupId>org.apache.zookeeper</groupId>
-      <artifactId>zookeeper-prometheus-metrics</artifactId>
-      <version>${zookeeper.version}</version>
-      <exclusions>
-        <exclusion>
-          <groupId>ch.qos.logback</groupId>
-          <artifactId>logback-core</artifactId>
-        </exclusion>
-        <exclusion>
-          <groupId>ch.qos.logback</groupId>
-          <artifactId>logback-classic</artifactId>
-        </exclusion>
-        <exclusion>
-          <groupId>io.netty</groupId>
-          <artifactId>netty-tcnative</artifactId>
-        </exclusion>
-      </exclusions>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>pulsar-zookeeper-prometheus-metrics</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+
+    <dependency>
+      <groupId>jline</groupId>
+      <artifactId>jline</artifactId>
+      <version>${jline.version}</version>
     </dependency>
 
     <dependency>
@@ -141,6 +133,12 @@
       <groupId>${project.groupId}</groupId>
       <artifactId>pulsar-testclient</artifactId>
       <version>${project.version}</version>
+      <exclusions>
+        <exclusion>
+          <groupId>org.apache.zookeeper</groupId>
+          <artifactId>zookeeper</artifactId>
+        </exclusion>
+      </exclusions>
     </dependency>
 
     <dependency>
@@ -199,11 +197,6 @@
       <artifactId>log4j-slf4j2-impl</artifactId>
     </dependency>
 
-    <dependency>
-      <groupId>org.apache.bookkeeper.stats</groupId>
-      <artifactId>prometheus-metrics-provider</artifactId>
-    </dependency>
-
     <dependency>
       <groupId>io.prometheus</groupId>
       <artifactId>simpleclient_log4j2</artifactId>
diff --git a/distribution/server/src/assemble/LICENSE.bin.txt b/distribution/server/src/assemble/LICENSE.bin.txt
index d6e03704578..de8de936a6d 100644
--- a/distribution/server/src/assemble/LICENSE.bin.txt
+++ b/distribution/server/src/assemble/LICENSE.bin.txt
@@ -330,7 +330,6 @@ The Apache Software License, Version 2.0
     - io.prometheus-simpleclient_common-0.16.0.jar
     - io.prometheus-simpleclient_hotspot-0.16.0.jar
     - io.prometheus-simpleclient_httpserver-0.16.0.jar
-    - io.prometheus-simpleclient_jetty-0.16.0.jar
     - io.prometheus-simpleclient_log4j2-0.16.0.jar
     - io.prometheus-simpleclient_servlet-0.16.0.jar
     - io.prometheus-simpleclient_servlet_common-0.16.0.jar
@@ -354,8 +353,8 @@ The Apache Software License, Version 2.0
     - org.apache.logging.log4j-log4j-web-2.25.4.jar
     - org.apache.logging.log4j-log4j-layout-template-json-2.25.4.jar
  * Java Native Access JNA
-    - net.java.dev.jna-jna-jpms-5.12.1.jar
-    - net.java.dev.jna-jna-platform-jpms-5.12.1.jar
+    - net.java.dev.jna-jna-jpms-5.18.1.jar
+    - net.java.dev.jna-jna-platform-jpms-5.18.1.jar
  * BookKeeper
     - org.apache.bookkeeper-bookkeeper-common-4.17.3.jar
     - org.apache.bookkeeper-bookkeeper-common-allocator-4.17.3.jar
@@ -376,12 +375,10 @@ The Apache Software License, Version 2.0
     - org.apache.bookkeeper.http-http-server-4.17.3.jar
     - org.apache.bookkeeper.http-vertx-http-server-4.17.3.jar
     - org.apache.bookkeeper.stats-bookkeeper-stats-api-4.17.3.jar
-    - org.apache.bookkeeper.stats-prometheus-metrics-provider-4.17.3.jar
     - org.apache.distributedlog-distributedlog-common-4.17.3.jar
     - org.apache.distributedlog-distributedlog-core-4.17.3-tests.jar
     - org.apache.distributedlog-distributedlog-core-4.17.3.jar
     - org.apache.distributedlog-distributedlog-protocol-4.17.3.jar
-    - org.apache.bookkeeper.stats-codahale-metrics-provider-4.17.3.jar
     - org.apache.bookkeeper-bookkeeper-slogger-api-4.17.3.jar
     - org.apache.bookkeeper-bookkeeper-slogger-slf4j-4.17.3.jar
     - org.apache.bookkeeper-native-io-4.17.3.jar
@@ -395,25 +392,43 @@ The Apache Software License, Version 2.0
     - org.asynchttpclient-async-http-client-2.14.5.jar
     - org.asynchttpclient-async-http-client-netty-utils-2.14.5.jar
  * Jetty
-    - org.eclipse.jetty-jetty-client-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-continuation-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-http-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-io-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-proxy-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-security-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-server-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-servlet-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-servlets-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-util-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-util-ajax-9.4.58.v20250814.jar
-    - org.eclipse.jetty.websocket-javax-websocket-client-impl-9.4.58.v20250814.jar
-    - org.eclipse.jetty.websocket-websocket-api-9.4.58.v20250814.jar
-    - org.eclipse.jetty.websocket-websocket-client-9.4.58.v20250814.jar
-    - org.eclipse.jetty.websocket-websocket-common-9.4.58.v20250814.jar
-    - org.eclipse.jetty.websocket-websocket-server-9.4.58.v20250814.jar
-    - org.eclipse.jetty.websocket-websocket-servlet-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-alpn-conscrypt-server-9.4.58.v20250814.jar
-    - org.eclipse.jetty-jetty-alpn-server-9.4.58.v20250814.jar
+    - org.eclipse.jetty-jetty-alpn-client-12.1.8.jar
+    - org.eclipse.jetty-jetty-alpn-conscrypt-server-12.1.8.jar
+    - org.eclipse.jetty-jetty-alpn-server-12.1.8.jar
+    - org.eclipse.jetty-jetty-annotations-12.1.8.jar
+    - org.eclipse.jetty-jetty-client-12.1.8.jar
+    - org.eclipse.jetty-jetty-http-12.1.8.jar
+    - org.eclipse.jetty-jetty-io-12.1.8.jar
+    - org.eclipse.jetty-jetty-jndi-12.1.8.jar
+    - org.eclipse.jetty-jetty-plus-12.1.8.jar
+    - org.eclipse.jetty-jetty-security-12.1.8.jar
+    - org.eclipse.jetty-jetty-server-12.1.8.jar
+    - org.eclipse.jetty-jetty-session-12.1.8.jar
+    - org.eclipse.jetty-jetty-util-12.1.8.jar
+    - org.eclipse.jetty-jetty-xml-12.1.8.jar
+    - org.eclipse.jetty.compression-jetty-compression-common-12.1.8.jar
+    - org.eclipse.jetty.compression-jetty-compression-gzip-12.1.8.jar
+    - org.eclipse.jetty.compression-jetty-compression-server-12.1.8.jar
+    - org.eclipse.jetty.ee-jetty-ee-webapp-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-annotations-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-nested-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-plus-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-proxy-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-security-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-servlet-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-servlets-12.1.8.jar
+    - org.eclipse.jetty.ee8-jetty-ee8-webapp-12.1.8.jar
+    - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-api-12.1.8.jar
+    - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-common-12.1.8.jar
+    - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-server-12.1.8.jar
+    - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-servlet-12.1.8.jar
+    - org.eclipse.jetty.toolchain-jetty-servlet-api-4.0.9.jar
+    - org.eclipse.jetty.websocket-jetty-websocket-core-client-12.1.8.jar
+    - org.eclipse.jetty.websocket-jetty-websocket-core-common-12.1.8.jar
+    - org.eclipse.jetty.websocket-jetty-websocket-core-server-12.1.8.jar
+    - org.eclipse.jetty.websocket-jetty-websocket-jetty-api-12.1.8.jar
+    - org.eclipse.jetty.websocket-jetty-websocket-jetty-client-12.1.8.jar
+    - org.eclipse.jetty.websocket-jetty-websocket-jetty-common-12.1.8.jar
  * SnakeYaml -- org.yaml-snakeyaml-2.0.jar
  * RocksDB - org.rocksdb-rocksdbjni-7.9.2.jar
  * Google Error Prone Annotations - com.google.errorprone-error_prone_annotations-2.45.0.jar
@@ -480,7 +495,6 @@ The Apache Software License, Version 2.0
     - io.dropwizard.metrics-metrics-core-4.1.12.1.jar
     - io.dropwizard.metrics-metrics-graphite-4.1.12.1.jar
     - io.dropwizard.metrics-metrics-jvm-4.1.12.1.jar
-    - io.dropwizard.metrics-metrics-jmx-4.1.12.1.jar
   * Prometheus
     - io.prometheus-simpleclient_httpserver-0.16.0.jar
   * Oxia
@@ -501,9 +515,7 @@ The Apache Software License, Version 2.0
     - io.vertx-vertx-web-4.5.24.jar
     - io.vertx-vertx-web-common-4.5.24.jar
   * Apache ZooKeeper
-    - org.apache.zookeeper-zookeeper-3.9.5.jar
     - org.apache.zookeeper-zookeeper-jute-3.9.5.jar
-    - org.apache.zookeeper-zookeeper-prometheus-metrics-3.9.5.jar
   * Snappy Java
     - org.xerial.snappy-snappy-java-1.1.10.8.jar
   * Google HTTP Client
@@ -556,6 +568,10 @@ BSD 3-clause "New" or "Revised" License
  * JSR305 -- com.google.code.findbugs-jsr305-3.0.2.jar -- ../licenses/LICENSE-JSR305.txt
  * JLine -- jline-jline-2.14.6.jar -- ../licenses/LICENSE-JLine.txt
  * JLine3 -- org.jline-jline-3.21.0.jar -- ../licenses/LICENSE-JLine.txt
+ * OW2 ASM
+   - org.ow2.asm-asm-9.9.1.jar -- ../licenses/LICENSE-ASM.txt
+   - org.ow2.asm-asm-commons-9.9.1.jar -- ../licenses/LICENSE-ASM.txt
+   - org.ow2.asm-asm-tree-9.9.1.jar -- ../licenses/LICENSE-ASM.txt
 
 BSD 2-Clause License
  * HdrHistogram -- org.hdrhistogram-HdrHistogram-2.1.9.jar -- ../licenses/LICENSE-HdrHistogram.txt
@@ -581,7 +597,6 @@ CDDL-1.1 -- ../licenses/LICENSE-CDDL-1.1.txt
  * Java Annotations API
     - com.sun.activation-jakarta.activation-1.2.2.jar
  * Java Servlet API -- javax.servlet-javax.servlet-api-3.1.0.jar
- * WebSocket Server API -- javax.websocket-javax.websocket-client-api-1.0.jar
  * HK2 - Dependency Injection Kernel
     - org.glassfish.hk2-hk2-api-2.6.1.jar
     - org.glassfish.hk2-hk2-locator-2.6.1.jar
@@ -609,6 +624,7 @@ Eclipse Public License - v2.0 -- ../licenses/LICENSE-EPL-2.0.txt
  * Jakarta Annotations API -- jakarta.annotation-jakarta.annotation-api-1.3.5.jar
  * Jakarta RESTful Web Services -- jakarta.ws.rs-jakarta.ws.rs-api-2.1.6.jar
  * Jakarta Injection -- org.glassfish.hk2.external-jakarta.inject-2.6.1.jar
+ * Jakarta Transactions API -- jakarta.transaction-jakarta.transaction-api-1.3.3.jar
 
 Public Domain (CC0) -- ../licenses/LICENSE-CC0.txt
  * Reactive Streams -- org.reactivestreams-reactive-streams-1.0.4.jar
diff --git a/distribution/server/src/assemble/bin.xml b/distribution/server/src/assemble/bin.xml
index 4dfec015c0e..ea359c97363 100644
--- a/distribution/server/src/assemble/bin.xml
+++ b/distribution/server/src/assemble/bin.xml
@@ -128,6 +128,8 @@
         <exclude>com.google.android:annotations</exclude>
         <!-- Needed only in the pulsar-shell distro only -->
         <exclude>net.java.dev.jna:jna</exclude>
+        <!-- Exclude org.apache.zookeeper:zookeeper since the patched version is included in the distribution -->
+        <exclude>org.apache.zookeeper:zookeeper</exclude>
       </excludes>
     </dependencySet>
   </dependencySets>
diff --git a/distribution/shell/src/assemble/LICENSE.bin.txt b/distribution/shell/src/assemble/LICENSE.bin.txt
index 3afd6092a39..33ec6103672 100644
--- a/distribution/shell/src/assemble/LICENSE.bin.txt
+++ b/distribution/shell/src/assemble/LICENSE.bin.txt
@@ -401,14 +401,18 @@ The Apache Software License, Version 2.0
     - async-http-client-2.14.5.jar
     - async-http-client-netty-utils-2.14.5.jar
  * Jetty
-    - jetty-client-9.4.58.v20250814.jar
-    - jetty-http-9.4.58.v20250814.jar
-    - jetty-io-9.4.58.v20250814.jar
-    - jetty-util-9.4.58.v20250814.jar
-    - javax-websocket-client-impl-9.4.58.v20250814.jar
-    - websocket-api-9.4.58.v20250814.jar
-    - websocket-client-9.4.58.v20250814.jar
-    - websocket-common-9.4.58.v20250814.jar
+    - jetty-alpn-client-12.1.8.jar
+    - jetty-client-12.1.8.jar
+    - jetty-compression-common-12.1.8.jar
+    - jetty-compression-gzip-12.1.8.jar
+    - jetty-http-12.1.8.jar
+    - jetty-io-12.1.8.jar
+    - jetty-util-12.1.8.jar
+    - jetty-websocket-core-client-12.1.8.jar
+    - jetty-websocket-core-common-12.1.8.jar
+    - jetty-websocket-jetty-api-12.1.8.jar
+    - jetty-websocket-jetty-client-12.1.8.jar
+    - jetty-websocket-jetty-common-12.1.8.jar
  * SnakeYaml -- snakeyaml-2.0.jar
  * Google Error Prone Annotations - error_prone_annotations-2.45.0.jar
  * Javassist -- javassist-3.25.0-GA.jar
@@ -434,7 +438,6 @@ MIT License
 CDDL-1.1 -- ../licenses/LICENSE-CDDL-1.1.txt
  * Java Annotations API
     - jakarta.activation-1.2.2.jar
- * WebSocket Server API -- javax.websocket-client-api-1.0.jar
  * HK2 - Dependency Injection Kernel
     - hk2-api-2.6.1.jar
     - hk2-locator-2.6.1.jar
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/pom.xml b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/pom.xml
new file mode 100644
index 00000000000..e864323275a
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/pom.xml
@@ -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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.pulsar</groupId>
+    <artifactId>jetty-upgrade</artifactId>
+    <version>4.0.10-SNAPSHOT</version>
+  </parent>
+  <artifactId>pulsar-bookkeeper-prometheus-metrics-provider</artifactId>
+  <name>Apache Pulsar :: BookKeeper Stats Providers :: Prometheus</name>
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.bookkeeper.stats</groupId>
+      <artifactId>bookkeeper-stats-api</artifactId>
+      <version>${bookkeeper.version}</version>
+    </dependency>
+
+    <dependency>
+      <groupId>io.prometheus</groupId>
+      <artifactId>simpleclient</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>io.prometheus</groupId>
+      <artifactId>simpleclient_hotspot</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>io.prometheus</groupId>
+      <artifactId>simpleclient_servlet</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>io.netty</groupId>
+      <artifactId>netty-common</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.netty</groupId>
+      <artifactId>netty-buffer</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty.ee8</groupId>
+      <artifactId>jetty-ee8-servlet</artifactId>
+    </dependency>
+
+
+    <dependency>
+      <groupId>com.google.guava</groupId>
+      <artifactId>guava</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>com.yahoo.datasketches</groupId>
+      <artifactId>sketches-core</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.bookkeeper</groupId>
+      <artifactId>testtools</artifactId>
+      <version>${bookkeeper.version}</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/DataSketchesOpStatsLogger.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/DataSketchesOpStatsLogger.java
new file mode 100644
index 00000000000..5c5bf560799
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/DataSketchesOpStatsLogger.java
@@ -0,0 +1,222 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import com.yahoo.sketches.quantiles.DoublesSketch;
+import com.yahoo.sketches.quantiles.DoublesSketchBuilder;
+import com.yahoo.sketches.quantiles.DoublesUnion;
+import com.yahoo.sketches.quantiles.DoublesUnionBuilder;
+import io.netty.util.concurrent.FastThreadLocal;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.LongAdder;
+import java.util.concurrent.locks.StampedLock;
+import org.apache.bookkeeper.stats.OpStatsData;
+import org.apache.bookkeeper.stats.OpStatsLogger;
+
+/**
+ * OpStatsLogger implementation that uses DataSketches library to calculate the approximated latency quantiles.
+ */
+public class DataSketchesOpStatsLogger implements OpStatsLogger {
+
+    /*
+     * Use 2 rotating thread local accessor so that we can safely swap them.
+     */
+    private volatile ThreadLocalAccessor current;
+    private volatile ThreadLocalAccessor replacement;
+
+    /*
+     * These are the sketches where all the aggregated results are published.
+     */
+    private volatile DoublesSketch successResult;
+    private volatile DoublesSketch failResult;
+
+    private final LongAdder successCountAdder = new LongAdder();
+    private final LongAdder failCountAdder = new LongAdder();
+
+    private final LongAdder successSumAdder = new LongAdder();
+    private final LongAdder failSumAdder = new LongAdder();
+
+    private Map<String, String> labels;
+
+    // used for lazy registration for thread scoped metrics
+    private boolean threadInitialized;
+
+    public DataSketchesOpStatsLogger(Map<String, String> labels) {
+        this.current = new ThreadLocalAccessor();
+        this.replacement = new ThreadLocalAccessor();
+        this.labels = labels;
+    }
+
+    @Override
+    public void registerFailedEvent(long eventLatency, TimeUnit unit) {
+        double valueMillis = unit.toMicros(eventLatency) / 1000.0;
+
+        failCountAdder.increment();
+        failSumAdder.add((long) valueMillis);
+
+        LocalData localData = current.localData.get();
+
+        long stamp = localData.lock.readLock();
+        try {
+            localData.failSketch.update(valueMillis);
+        } finally {
+            localData.lock.unlockRead(stamp);
+        }
+    }
+
+    @Override
+    public void registerSuccessfulEvent(long eventLatency, TimeUnit unit) {
+        double valueMillis = unit.toMicros(eventLatency) / 1000.0;
+
+        successCountAdder.increment();
+        successSumAdder.add((long) valueMillis);
+
+        LocalData localData = current.localData.get();
+
+        long stamp = localData.lock.readLock();
+        try {
+            localData.successSketch.update(valueMillis);
+        } finally {
+            localData.lock.unlockRead(stamp);
+        }
+    }
+
+    @Override
+    public void registerSuccessfulValue(long value) {
+        successCountAdder.increment();
+        successSumAdder.add(value);
+
+        LocalData localData = current.localData.get();
+
+        long stamp = localData.lock.readLock();
+        try {
+            localData.successSketch.update(value);
+        } finally {
+            localData.lock.unlockRead(stamp);
+        }
+    }
+
+    @Override
+    public void registerFailedValue(long value) {
+        failCountAdder.increment();
+        failSumAdder.add(value);
+
+        LocalData localData = current.localData.get();
+
+        long stamp = localData.lock.readLock();
+        try {
+            localData.failSketch.update(value);
+        } finally {
+            localData.lock.unlockRead(stamp);
+        }
+    }
+
+    @Override
+    public OpStatsData toOpStatsData() {
+        // Not relevant as we don't use JMX here
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void clear() {
+        // Not relevant as we don't use JMX here
+        throw new UnsupportedOperationException();
+    }
+
+    public void rotateLatencyCollection() {
+        // Swap current with replacement
+        ThreadLocalAccessor local = current;
+        current = replacement;
+        replacement = local;
+
+        final DoublesUnion aggregateSuccesss = new DoublesUnionBuilder().build();
+        final DoublesUnion aggregateFail = new DoublesUnionBuilder().build();
+        local.map.forEach((localData, b) -> {
+            long stamp = localData.lock.writeLock();
+            try {
+                aggregateSuccesss.update(localData.successSketch);
+                localData.successSketch.reset();
+                aggregateFail.update(localData.failSketch);
+                localData.failSketch.reset();
+            } finally {
+                localData.lock.unlockWrite(stamp);
+            }
+        });
+
+        successResult = aggregateSuccesss.getResultAndReset();
+        failResult = aggregateFail.getResultAndReset();
+    }
+
+    public long getCount(boolean success) {
+        return success ? successCountAdder.sum() : failCountAdder.sum();
+    }
+
+    public long getSum(boolean success) {
+        return success ? successSumAdder.sum() : failSumAdder.sum();
+    }
+
+    public double getQuantileValue(boolean success, double quantile) {
+        DoublesSketch s = success ? successResult : failResult;
+        return s != null ? s.getQuantile(quantile) : Double.NaN;
+    }
+
+    public Map<String, String> getLabels() {
+        return labels;
+    }
+
+    public boolean isThreadInitialized() {
+        return threadInitialized;
+    }
+
+    public void initializeThread(Map<String, String> labels) {
+        this.labels = labels;
+        this.threadInitialized = true;
+    }
+
+    private static class LocalData {
+        private final DoublesSketch successSketch = new DoublesSketchBuilder().build();
+        private final DoublesSketch failSketch = new DoublesSketchBuilder().build();
+        private final StampedLock lock = new StampedLock();
+    }
+
+    private static class ThreadLocalAccessor {
+        private final Map<LocalData, Boolean> map = new ConcurrentHashMap<>();
+        private final FastThreadLocal<LocalData> localData = new FastThreadLocal<LocalData>() {
+
+            @Override
+            protected LocalData initialValue() throws Exception {
+                LocalData localData = new LocalData();
+                map.put(localData, Boolean.TRUE);
+                return localData;
+            }
+
+            @Override
+            protected void onRemoval(LocalData value) throws Exception {
+                map.remove(value);
+            }
+        };
+    }
+
+    @Override
+    public String toString() {
+        return "DataSketchesOpStatsLogger{labels=" + labels + ", id=" + System.identityHashCode(this) + "}";
+    }
+}
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/LongAdderCounter.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/LongAdderCounter.java
new file mode 100644
index 00000000000..a5a8e4d2f02
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/LongAdderCounter.java
@@ -0,0 +1,91 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.LongAdder;
+import org.apache.bookkeeper.stats.Counter;
+
+/**
+ * {@link Counter} implementation based on {@link LongAdder}.
+ *
+ * <p>LongAdder keeps a counter per-thread and then aggregates to get the result, in order to avoid contention between
+ * multiple threads.
+ */
+public class LongAdderCounter implements Counter {
+    private final LongAdder counter = new LongAdder();
+
+    private Map<String, String> labels;
+
+    // used for lazy registration for thread scoped metric
+    private boolean threadInitialized;
+
+    public LongAdderCounter(Map<String, String> labels) {
+        this.labels = labels;
+    }
+
+    @Override
+    public void clear() {
+        counter.reset();
+    }
+
+    @Override
+    public void inc() {
+        counter.increment();
+    }
+
+    @Override
+    public void dec() {
+        counter.decrement();
+    }
+
+    @Override
+    public void addCount(long delta) {
+        counter.add(delta);
+    }
+
+    /**
+     * When counter is used to count time.
+     * consistent with the {@link DataSketchesOpStatsLogger#registerSuccessfulEvent(long, TimeUnit)} 's logic
+     * */
+    @Override
+    public void addLatency(long eventLatency, TimeUnit unit) {
+        long valueMillis = unit.toMillis(eventLatency);
+        counter.add(valueMillis);
+    }
+
+    @Override
+    public Long get() {
+        return counter.sum();
+    }
+
+    public Map<String, String> getLabels() {
+        return labels;
+    }
+
+    public boolean isThreadInitialized() {
+        return threadInitialized;
+    }
+
+    public void initializeThread(Map<String, String> labels) {
+        this.labels = labels;
+        this.threadInitialized = true;
+    }
+}
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusMetricsProvider.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusMetricsProvider.java
new file mode 100644
index 00000000000..e5568625328
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusMetricsProvider.java
@@ -0,0 +1,253 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+// CHECKSTYLE.OFF: IllegalImport
+
+import com.google.common.annotations.VisibleForTesting;
+import io.netty.util.concurrent.DefaultThreadFactory;
+import io.netty.util.internal.PlatformDependent;
+import io.prometheus.client.Collector;
+import io.prometheus.client.CollectorRegistry;
+import io.prometheus.client.Gauge;
+import io.prometheus.client.Gauge.Child;
+import io.prometheus.client.hotspot.GarbageCollectorExports;
+import io.prometheus.client.hotspot.MemoryPoolsExports;
+import io.prometheus.client.hotspot.StandardExports;
+import io.prometheus.client.hotspot.ThreadExports;
+import java.io.IOException;
+import java.io.Writer;
+import java.lang.management.BufferPoolMXBean;
+import java.lang.management.ManagementFactory;
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+import org.apache.bookkeeper.stats.StatsLogger;
+import org.apache.bookkeeper.stats.StatsProvider;
+import org.apache.bookkeeper.stats.ThreadRegistry;
+import org.apache.commons.configuration2.Configuration;
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.jetty.ee8.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee8.servlet.ServletHolder;
+import org.eclipse.jetty.server.Server;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+// CHECKSTYLE.ON: IllegalImport
+
+/**
+ * A <i>Prometheus</i> based {@link StatsProvider} implementation.
+ */
+public class PrometheusMetricsProvider implements StatsProvider {
+
+    private ScheduledExecutorService executor;
+
+    public static final String PROMETHEUS_STATS_HTTP_ENABLE = "prometheusStatsHttpEnable";
+    public static final boolean DEFAULT_PROMETHEUS_STATS_HTTP_ENABLE = true;
+
+    public static final String PROMETHEUS_STATS_HTTP_ADDRESS = "prometheusStatsHttpAddress";
+    public static final String DEFAULT_PROMETHEUS_STATS_HTTP_ADDR = "0.0.0.0";
+
+    public static final String PROMETHEUS_STATS_HTTP_PORT = "prometheusStatsHttpPort";
+    public static final int DEFAULT_PROMETHEUS_STATS_HTTP_PORT = 8000;
+
+    public static final String PROMETHEUS_STATS_LATENCY_ROLLOVER_SECONDS = "prometheusStatsLatencyRolloverSeconds";
+    public static final int DEFAULT_PROMETHEUS_STATS_LATENCY_ROLLOVER_SECONDS = 60;
+
+    final CollectorRegistry registry;
+
+    Server server;
+
+    /*
+     * These acts a registry of the metrics defined in this provider
+     */
+    final ConcurrentMap<ScopeContext, LongAdderCounter> counters = new ConcurrentHashMap<>();
+    final ConcurrentMap<ScopeContext, SimpleGauge<? extends Number>> gauges = new ConcurrentHashMap<>();
+    final ConcurrentMap<ScopeContext, DataSketchesOpStatsLogger> opStats = new ConcurrentHashMap<>();
+    final ConcurrentMap<ScopeContext, ThreadScopedDataSketchesStatsLogger> threadScopedOpStats =
+            new ConcurrentHashMap<>();
+    final ConcurrentMap<ScopeContext, ThreadScopedLongAdderCounter> threadScopedCounters =
+            new ConcurrentHashMap<>();
+
+    public PrometheusMetricsProvider() {
+        this(CollectorRegistry.defaultRegistry);
+    }
+
+    public PrometheusMetricsProvider(CollectorRegistry registry) {
+        this.registry = registry;
+    }
+
+    @Override
+    public void start(Configuration conf) {
+        boolean httpEnabled = conf.getBoolean(PROMETHEUS_STATS_HTTP_ENABLE, DEFAULT_PROMETHEUS_STATS_HTTP_ENABLE);
+        boolean bkHttpServerEnabled = conf.getBoolean("httpServerEnabled", false);
+        boolean exposeDefaultJVMMetrics = conf.getBoolean("exposeDefaultJVMMetrics", true);
+        // only start its own http server when prometheus http is enabled and bk http server is not enabled.
+        if (httpEnabled && !bkHttpServerEnabled) {
+            String httpAddr = conf.getString(PROMETHEUS_STATS_HTTP_ADDRESS, DEFAULT_PROMETHEUS_STATS_HTTP_ADDR);
+            int httpPort = conf.getInt(PROMETHEUS_STATS_HTTP_PORT, DEFAULT_PROMETHEUS_STATS_HTTP_PORT);
+            InetSocketAddress httpEndpoint = InetSocketAddress.createUnresolved(httpAddr, httpPort);
+            this.server = new Server(httpEndpoint);
+            ServletContextHandler context = new ServletContextHandler();
+            context.setContextPath("/");
+            server.setHandler(context);
+
+            context.addServlet(new ServletHolder(new PrometheusServlet(this)), "/metrics");
+
+            try {
+                server.start();
+                log.info("Started Prometheus stats endpoint at {}", httpEndpoint);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        if (exposeDefaultJVMMetrics) {
+            // Include standard JVM stats
+            registerMetrics(new StandardExports());
+            registerMetrics(new MemoryPoolsExports());
+            registerMetrics(new GarbageCollectorExports());
+            registerMetrics(new ThreadExports());
+
+        // Add direct memory allocated through unsafe
+            registerMetrics(Gauge.build("jvm_memory_direct_bytes_used", "-").create().setChild(new Child() {
+                @Override
+                public double get() {
+                    return getDirectMemoryUsage.get();
+                }
+            }));
+
+            registerMetrics(Gauge.build("jvm_memory_direct_bytes_max", "-").create().setChild(new Child() {
+                @Override
+                public double get() {
+                    return PlatformDependent.estimateMaxDirectMemory();
+                }
+            }));
+        }
+
+        executor = Executors.newSingleThreadScheduledExecutor(new DefaultThreadFactory("metrics"));
+
+        int latencyRolloverSeconds = conf.getInt(PROMETHEUS_STATS_LATENCY_ROLLOVER_SECONDS,
+                DEFAULT_PROMETHEUS_STATS_LATENCY_ROLLOVER_SECONDS);
+
+        executor.scheduleAtFixedRate(() -> {
+            rotateLatencyCollection();
+        }, 1, latencyRolloverSeconds, TimeUnit.SECONDS);
+
+    }
+
+    @Override
+    public void stop() {
+        if (server != null) {
+            try {
+                server.stop();
+            } catch (Exception e) {
+                log.warn("Failed to shutdown Jetty server", e);
+            } finally {
+                ThreadRegistry.clear();
+            }
+        }
+    }
+
+    @Override
+    public StatsLogger getStatsLogger(String scope) {
+        return new PrometheusStatsLogger(PrometheusMetricsProvider.this, scope, Collections.emptyMap());
+    }
+
+    @Override
+    public void writeAllMetrics(Writer writer) throws IOException {
+        PrometheusTextFormat prometheusTextFormat = new PrometheusTextFormat();
+        PrometheusTextFormat.writeMetricsCollectedByPrometheusClient(writer, registry);
+
+        gauges.forEach((sc, gauge) -> prometheusTextFormat.writeGauge(writer, sc.getScope(), gauge));
+        counters.forEach((sc, counter) -> prometheusTextFormat.writeCounter(writer, sc.getScope(), counter));
+        opStats.forEach((sc, opStatLogger) ->
+                prometheusTextFormat.writeOpStat(writer, sc.getScope(), opStatLogger));
+    }
+
+    @Override
+    public String getStatsName(String... statsComponents) {
+        String completeName;
+        if (statsComponents.length == 0) {
+            return "";
+        } else if (statsComponents[0].isEmpty()) {
+            completeName = StringUtils.join(statsComponents, '_', 1, statsComponents.length);
+        } else {
+            completeName = StringUtils.join(statsComponents, '_');
+        }
+        return Collector.sanitizeMetricName(completeName);
+    }
+
+    @VisibleForTesting
+    void rotateLatencyCollection() {
+        opStats.forEach((name, metric) -> {
+            metric.rotateLatencyCollection();
+        });
+    }
+
+    private void registerMetrics(Collector collector) {
+        try {
+            collector.register(registry);
+        } catch (Exception e) {
+            // Ignore if these were already registered
+            if (log.isDebugEnabled()) {
+                log.debug("Failed to register Prometheus collector exports", e);
+            }
+        }
+    }
+
+    private static final Logger log = LoggerFactory.getLogger(PrometheusMetricsProvider.class);
+
+    /*
+     * Try to get Netty counter of used direct memory. This will be correct, unlike the JVM values.
+     */
+    private static final AtomicLong directMemoryUsage;
+    private static final Optional<BufferPoolMXBean> poolMxBeanOp;
+    private static final Supplier<Double> getDirectMemoryUsage;
+
+    static {
+        if (PlatformDependent.useDirectBufferNoCleaner()) {
+            poolMxBeanOp = Optional.empty();
+            AtomicLong tmpDirectMemoryUsage = null;
+            try {
+                Field field = PlatformDependent.class.getDeclaredField("DIRECT_MEMORY_COUNTER");
+                field.setAccessible(true);
+                tmpDirectMemoryUsage = (AtomicLong) field.get(null);
+            } catch (Throwable t) {
+                log.warn("Failed to access netty DIRECT_MEMORY_COUNTER field {}", t.getMessage());
+            }
+            directMemoryUsage = tmpDirectMemoryUsage;
+            getDirectMemoryUsage = () -> directMemoryUsage != null ? directMemoryUsage.get() : Double.NaN;
+        } else {
+            directMemoryUsage = null;
+            List<BufferPoolMXBean> platformMXBeans = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
+            poolMxBeanOp = platformMXBeans.stream()
+                    .filter(bufferPoolMXBean -> bufferPoolMXBean.getName().equals("direct")).findAny();
+            getDirectMemoryUsage = () -> poolMxBeanOp.isPresent() ? poolMxBeanOp.get().getMemoryUsed() : Double.NaN;
+        }
+    }
+}
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusServlet.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusServlet.java
new file mode 100644
index 00000000000..88c572d8c4d
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusServlet.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.pulsar.metrics.prometheus.bookkeeper;
+
+import io.prometheus.client.exporter.common.TextFormat;
+import java.io.IOException;
+import java.io.Writer;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Servlet used to export metrics in prometheus text format.
+ */
+public class PrometheusServlet extends HttpServlet {
+    private static final long serialVersionUID = 1L;
+
+    private final transient PrometheusMetricsProvider provider;
+
+    public PrometheusServlet(PrometheusMetricsProvider provider) {
+        this.provider = provider;
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        resp.setStatus(HttpServletResponse.SC_OK);
+        resp.setContentType(TextFormat.CONTENT_TYPE_004);
+
+        Writer writer = resp.getWriter();
+        try {
+            provider.writeAllMetrics(writer);
+            writer.flush();
+        } finally {
+            writer.close();
+        }
+    }
+
+    @Override
+    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        doGet(req, resp);
+    }
+
+}
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusStatsLogger.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusStatsLogger.java
new file mode 100644
index 00000000000..b3f8098590f
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusStatsLogger.java
@@ -0,0 +1,100 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import com.google.common.base.Joiner;
+import io.prometheus.client.Collector;
+import java.util.Map;
+import java.util.TreeMap;
+import org.apache.bookkeeper.stats.Counter;
+import org.apache.bookkeeper.stats.Gauge;
+import org.apache.bookkeeper.stats.OpStatsLogger;
+import org.apache.bookkeeper.stats.StatsLogger;
+
+/**
+ * A {@code Prometheus} based {@link StatsLogger} implementation.
+ */
+public class PrometheusStatsLogger implements StatsLogger {
+
+    private final PrometheusMetricsProvider provider;
+    private final String scope;
+    private final Map<String, String> labels;
+
+    PrometheusStatsLogger(PrometheusMetricsProvider provider, String scope, Map<String, String> labels) {
+        this.provider = provider;
+        this.scope = scope;
+        this.labels = labels;
+    }
+
+    @Override
+    public OpStatsLogger getOpStatsLogger(String name) {
+        return provider.opStats.computeIfAbsent(scopeContext(name), x -> new DataSketchesOpStatsLogger(labels));
+    }
+
+    @Override
+    public OpStatsLogger getThreadScopedOpStatsLogger(String name) {
+        return provider.threadScopedOpStats.computeIfAbsent(scopeContext(name),
+                x -> new ThreadScopedDataSketchesStatsLogger(provider, x, labels));
+    }
+
+    @Override
+    public Counter getCounter(String name) {
+        return provider.counters.computeIfAbsent(scopeContext(name), x -> new LongAdderCounter(labels));
+    }
+
+    public Counter getThreadScopedCounter(String name) {
+        return provider.threadScopedCounters.computeIfAbsent(scopeContext(name),
+                x -> new ThreadScopedLongAdderCounter(provider, x, labels));
+    }
+
+    @Override
+    public <T extends Number> void registerGauge(String name, Gauge<T> gauge) {
+        provider.gauges.computeIfAbsent(scopeContext(name), x -> new SimpleGauge<T>(gauge, labels));
+    }
+
+    @Override
+    public <T extends Number> void unregisterGauge(String name, Gauge<T> gauge) {
+        // no-op
+    }
+
+    @Override
+    public void removeScope(String name, StatsLogger statsLogger) {
+        // no-op
+    }
+
+    @Override
+    public StatsLogger scope(String name) {
+        return new PrometheusStatsLogger(provider, completeName(name), labels);
+    }
+
+    @Override
+    public StatsLogger scopeLabel(String labelName, String labelValue) {
+        Map<String, String> newLabels = new TreeMap<>(labels);
+        newLabels.put(labelName, labelValue);
+        return new PrometheusStatsLogger(provider, scope, newLabels);
+    }
+
+    private ScopeContext scopeContext(String name) {
+        return new ScopeContext(completeName(name), labels);
+    }
+
+    private String completeName(String name) {
+        return Collector.sanitizeMetricName(scope.isEmpty() ? name : Joiner.on('_').join(scope, name));
+    }
+}
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusTextFormat.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusTextFormat.java
new file mode 100644
index 00000000000..97ad0ea8fbe
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/PrometheusTextFormat.java
@@ -0,0 +1,240 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import io.prometheus.client.Collector;
+import io.prometheus.client.Collector.MetricFamilySamples;
+import io.prometheus.client.Collector.MetricFamilySamples.Sample;
+import io.prometheus.client.CollectorRegistry;
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Logic to write metrics in Prometheus text format.
+ */
+public class PrometheusTextFormat {
+
+    Set<String> metricNameSet = new HashSet<>();
+
+    void writeGauge(Writer w, String name, SimpleGauge<? extends Number> gauge) {
+        // Example:
+        // # TYPE bookie_storage_entries_count gauge
+        // bookie_storage_entries_count 519
+        try {
+            writeType(w, name, "gauge");
+            w.append(name);
+            writeLabels(w, gauge.getLabels());
+            w.append(' ').append(gauge.getSample().toString()).append('\n');
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    void writeCounter(Writer w, String name, LongAdderCounter counter) {
+        // Example:
+        // # TYPE jvm_threads_started_total counter
+        // jvm_threads_started_total 59
+        try {
+            writeType(w, name, "counter");
+            w.append(name);
+            writeLabels(w, counter.getLabels());
+            w.append(' ').append(counter.get().toString()).append('\n');
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    void writeOpStat(Writer w, String name, DataSketchesOpStatsLogger opStat) {
+        // Example:
+        // # TYPE bookie_journal_JOURNAL_ADD_ENTRY summary
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="false",quantile="0.5",} NaN
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="false",quantile="0.75",} NaN
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="false",quantile="0.95",} NaN
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="false",quantile="0.99",} NaN
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="false",quantile="0.999",} NaN
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="false",quantile="0.9999",} NaN
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="false",quantile="1.0",} NaN
+        // bookie_journal_JOURNAL_ADD_ENTRY_count{success="false",} 0.0
+        // bookie_journal_JOURNAL_ADD_ENTRY_sum{success="false",} 0.0
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="true",quantile="0.5",} 1.706
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="true",quantile="0.75",} 1.89
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="true",quantile="0.95",} 2.121
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="true",quantile="0.99",} 10.708
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="true",quantile="0.999",} 10.902
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="true",quantile="0.9999",} 10.902
+        // bookie_journal_JOURNAL_ADD_ENTRY{success="true",quantile="1.0",} 10.902
+        // bookie_journal_JOURNAL_ADD_ENTRY_count{success="true",} 658.0
+        // bookie_journal_JOURNAL_ADD_ENTRY_sum{success="true",} 1265.0800000000002
+        try {
+            writeType(w, name, "summary");
+            writeQuantile(w, opStat, name, false, 0.5);
+            writeQuantile(w, opStat, name, false, 0.75);
+            writeQuantile(w, opStat, name, false, 0.95);
+            writeQuantile(w, opStat, name, false, 0.99);
+            writeQuantile(w, opStat, name, false, 0.999);
+            writeQuantile(w, opStat, name, false, 0.9999);
+            writeQuantile(w, opStat, name, false, 1.0);
+            writeCount(w, opStat, name, false);
+            writeSum(w, opStat, name, false);
+
+            writeQuantile(w, opStat, name, true, 0.5);
+            writeQuantile(w, opStat, name, true, 0.75);
+            writeQuantile(w, opStat, name, true, 0.95);
+            writeQuantile(w, opStat, name, true, 0.99);
+            writeQuantile(w, opStat, name, true, 0.999);
+            writeQuantile(w, opStat, name, true, 0.9999);
+            writeQuantile(w, opStat, name, true, 1.0);
+            writeCount(w, opStat, name, true);
+            writeSum(w, opStat, name, true);
+
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private void writeLabels(Writer w, Map<String, String> labels) throws IOException {
+        if (labels.isEmpty()) {
+            return;
+        }
+
+        w.append('{');
+        writeLabelsNoBraces(w, labels);
+        w.append('}');
+    }
+
+    private void writeLabelsNoBraces(Writer w, Map<String, String> labels) throws IOException {
+        if (labels.isEmpty()) {
+            return;
+        }
+
+        boolean isFirst = true;
+        for (Map.Entry<String, String> e : labels.entrySet()) {
+            if (!isFirst) {
+                w.append(',');
+            }
+            isFirst = false;
+            w.append(e.getKey())
+                    .append("=\"")
+                    .append(e.getValue())
+                    .append('"');
+        }
+    }
+
+    private void writeQuantile(Writer w, DataSketchesOpStatsLogger opStat, String name, Boolean success,
+            double quantile) throws IOException {
+        w.append(name)
+                .append("{success=\"").append(success.toString())
+                .append("\",quantile=\"").append(Double.toString(quantile))
+                .append("\"");
+        if (!opStat.getLabels().isEmpty()) {
+            w.append(", ");
+            writeLabelsNoBraces(w, opStat.getLabels());
+        }
+        w.append("} ")
+                .append(Double.toString(opStat.getQuantileValue(success, quantile))).append('\n');
+    }
+
+    private void writeCount(Writer w, DataSketchesOpStatsLogger opStat, String name, Boolean success)
+            throws IOException {
+        w.append(name).append("_count{success=\"").append(success.toString()).append("\"");
+        if (!opStat.getLabels().isEmpty()) {
+            w.append(", ");
+            writeLabelsNoBraces(w, opStat.getLabels());
+        }
+        w.append("} ")
+                .append(Long.toString(opStat.getCount(success))).append('\n');
+    }
+
+    private void writeSum(Writer w, DataSketchesOpStatsLogger opStat, String name, Boolean success)
+            throws IOException {
+        w.append(name).append("_sum{success=\"").append(success.toString()).append("\"");
+        if (!opStat.getLabels().isEmpty()) {
+            w.append(", ");
+            writeLabelsNoBraces(w, opStat.getLabels());
+        }
+        w.append("} ")
+                .append(Double.toString(opStat.getSum(success))).append('\n');
+    }
+
+    static void writeMetricsCollectedByPrometheusClient(Writer w, CollectorRegistry registry) throws IOException {
+        Enumeration<MetricFamilySamples> metricFamilySamples = registry.metricFamilySamples();
+        while (metricFamilySamples.hasMoreElements()) {
+            MetricFamilySamples metricFamily = metricFamilySamples.nextElement();
+            // Write type of metric
+            w.append("# TYPE ").append(metricFamily.name).append(getTypeNameSuffix(metricFamily.type)).append(' ')
+                    .append(getTypeStr(metricFamily.type)).write('\n');
+
+            for (int i = 0; i < metricFamily.samples.size(); i++) {
+                Sample sample = metricFamily.samples.get(i);
+                w.write(sample.name);
+                w.write('{');
+                for (int j = 0; j < sample.labelNames.size(); j++) {
+                    if (j != 0) {
+                        w.write(", ");
+                    }
+                    w.write(sample.labelNames.get(j));
+                    w.write("=\"");
+                    w.write(sample.labelValues.get(j));
+                    w.write('"');
+                }
+
+                w.write("} ");
+                w.write(Collector.doubleToGoString(sample.value));
+                w.write('\n');
+            }
+        }
+    }
+
+    static String getTypeNameSuffix(Collector.Type type) {
+        if (type.equals(Collector.Type.INFO)) {
+            return "_info";
+        }
+        return "";
+    }
+
+    static String getTypeStr(Collector.Type type) {
+        switch (type) {
+            case COUNTER:
+                return "counter";
+            case GAUGE:
+            case INFO:
+                return "gauge";
+            case SUMMARY:
+                return "summary";
+            case HISTOGRAM:
+                return "histogram";
+            case UNKNOWN:
+            default:
+                return "unknown";
+        }
+    }
+
+    void writeType(Writer w, String name, String type) throws IOException {
+        if (metricNameSet.contains(name)) {
+            return;
+        }
+        metricNameSet.add(name);
+        w.append("# TYPE ").append(name).append(" ").append(type).append("\n");
+    }
+
+}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ScopeContext.java
similarity index 50%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ScopeContext.java
index 65cc10e0d3b..4f15c006569 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ScopeContext.java
@@ -16,30 +16,41 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
+package org.apache.pulsar.metrics.prometheus.bookkeeper;
 
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
+import java.util.Map;
+import java.util.Objects;
 
-public class MockAdditionalServlet implements AdditionalServlet {
-
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
+/**
+ * Holder for a scope and a set of associated labels.
+ */
+public class ScopeContext {
+    private final String scope;
+    private final Map<String, String> labels;
 
+    public ScopeContext(String scope, Map<String, String> labels) {
+        this.scope = scope;
+        this.labels = labels;
     }
 
-    @Override
-    public String getBasePath() {
-        return null;
+    public String getScope() {
+        return scope;
     }
 
     @Override
-    public ServletHolder getServletHolder() {
-        return null;
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        ScopeContext that = (ScopeContext) o;
+        return Objects.equals(scope, that.scope) && Objects.equals(labels, that.labels);
     }
 
     @Override
-    public void close() {
-        // no-op
+    public int hashCode() {
+        return Objects.hash(scope, labels);
     }
 }
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/SimpleGauge.java
similarity index 58%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/SimpleGauge.java
index 65cc10e0d3b..384cc682c2f 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/SimpleGauge.java
@@ -16,30 +16,29 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
+package org.apache.pulsar.metrics.prometheus.bookkeeper;
 
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
+import java.util.Map;
+import org.apache.bookkeeper.stats.Gauge;
 
-public class MockAdditionalServlet implements AdditionalServlet {
-
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
+/**
+ * A {@link Gauge} implementation that forwards on the value supplier.
+ */
+public class SimpleGauge<T extends Number> {
 
-    }
+    private final Map<String, String> labels;
+    private final Gauge<T> gauge;
 
-    @Override
-    public String getBasePath() {
-        return null;
+    public SimpleGauge(final Gauge<T> gauge, Map<String, String> labels) {
+        this.gauge = gauge;
+        this.labels = labels;
     }
 
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
+    Number getSample() {
+        return gauge.getSample();
     }
 
-    @Override
-    public void close() {
-        // no-op
+    public Map<String, String> getLabels() {
+        return labels;
     }
 }
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ThreadScopedDataSketchesStatsLogger.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ThreadScopedDataSketchesStatsLogger.java
new file mode 100644
index 00000000000..4d3d5662d9c
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ThreadScopedDataSketchesStatsLogger.java
@@ -0,0 +1,137 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.bookkeeper.stats.OpStatsData;
+import org.apache.bookkeeper.stats.OpStatsLogger;
+import org.apache.bookkeeper.stats.ThreadRegistry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * OpStatsLogger implementation that lazily registers OpStatsLoggers per thread
+ * with added labels for the threadpool/thresd name and thread no.
+ */
+public class ThreadScopedDataSketchesStatsLogger implements OpStatsLogger {
+
+    private static Logger logger = LoggerFactory.getLogger(ThreadScopedDataSketchesStatsLogger.class);
+
+    private ThreadLocal<DataSketchesOpStatsLogger> statsLoggers;
+    private DataSketchesOpStatsLogger defaultStatsLogger;
+    private Map<String, String> originalLabels;
+    private ScopeContext scopeContext;
+    private PrometheusMetricsProvider provider;
+
+    public ThreadScopedDataSketchesStatsLogger(PrometheusMetricsProvider provider,
+                                               ScopeContext scopeContext,
+                                               Map<String, String> labels) {
+        this.provider = provider;
+        this.scopeContext = scopeContext;
+        this.originalLabels = labels;
+        this.defaultStatsLogger = new DataSketchesOpStatsLogger(labels);
+
+        Map<String, String> defaultLabels = new HashMap<>(labels);
+        defaultLabels.put("threadPool", "?");
+        defaultLabels.put("thread", "?");
+        this.defaultStatsLogger.initializeThread(defaultLabels);
+
+        this.statsLoggers = ThreadLocal.withInitial(() -> {
+            return new DataSketchesOpStatsLogger(labels);
+        });
+    }
+
+    @Override
+    public void registerFailedEvent(long eventLatency, TimeUnit unit) {
+        getStatsLogger().registerFailedEvent(eventLatency, unit);
+    }
+
+    @Override
+    public void registerSuccessfulEvent(long eventLatency, TimeUnit unit) {
+        getStatsLogger().registerSuccessfulEvent(eventLatency, unit);
+    }
+
+    @Override
+    public void registerSuccessfulValue(long value) {
+        getStatsLogger().registerSuccessfulValue(value);
+    }
+
+    @Override
+    public void registerFailedValue(long value) {
+        getStatsLogger().registerFailedValue(value);
+    }
+
+    @Override
+    public OpStatsData toOpStatsData() {
+        // Not relevant as we don't use JMX here
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void clear() {
+        // Not relevant as we don't use JMX here
+        throw new UnsupportedOperationException();
+    }
+
+    private DataSketchesOpStatsLogger getStatsLogger() {
+        DataSketchesOpStatsLogger statsLogger = statsLoggers.get();
+
+        // Lazy registration
+        // Update the stats logger with the thread labels then add to the provider
+        // If for some reason this thread did not get registered,
+        // then we fallback to a standard OpsStatsLogger (defaultStatsLogger)
+        if (!statsLogger.isThreadInitialized()) {
+            ThreadRegistry.ThreadPoolThread tpt = ThreadRegistry.get();
+            if (tpt == null) {
+                logger.warn("Thread {} was not registered in the thread registry. Using default stats logger {}.",
+                        Thread.currentThread(), defaultStatsLogger);
+                statsLoggers.set(defaultStatsLogger);
+                DataSketchesOpStatsLogger previous = provider.opStats
+                        .put(new ScopeContext(scopeContext.getScope(), originalLabels), defaultStatsLogger);
+                // If we overwrite a logger, metrics will not be collected correctly
+                if (previous != null && previous != defaultStatsLogger) {
+                    logger.error("Invalid state for thead " + Thread.currentThread() + ". Overwrote a stats logger."
+                                    + "New is {}, previous was {}",
+                            defaultStatsLogger, previous);
+                    throw new IllegalStateException("Invalid state. Overwrote a stats logger.");
+                }
+                return defaultStatsLogger;
+            } else {
+                Map<String, String> threadScopedlabels = new HashMap<>(originalLabels);
+                threadScopedlabels.put("threadPool", tpt.getThreadPool());
+                threadScopedlabels.put("thread", String.valueOf(tpt.getOrdinal()));
+
+                statsLogger.initializeThread(threadScopedlabels);
+                DataSketchesOpStatsLogger previous = provider.opStats
+                        .put(new ScopeContext(scopeContext.getScope(), threadScopedlabels), statsLogger);
+                // If we overwrite a logger, metrics will not be collected correctly
+                if (previous != null && previous != statsLogger) {
+                    logger.error("Invalid state for thead " + Thread.currentThread() + ". Overwrote a stats logger."
+                                    + "New is {}, previous was {}",
+                            defaultStatsLogger, previous);
+                    throw new IllegalStateException("Invalid state. Overwrote a stats logger.");
+                }
+            }
+        }
+
+        return statsLogger;
+    }
+}
\ No newline at end of file
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ThreadScopedLongAdderCounter.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ThreadScopedLongAdderCounter.java
new file mode 100644
index 00000000000..1c2287f5b9c
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/ThreadScopedLongAdderCounter.java
@@ -0,0 +1,111 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.bookkeeper.stats.Counter;
+import org.apache.bookkeeper.stats.ThreadRegistry;
+
+/**
+ * {@link Counter} implementation that lazily registers LongAdderCounters per thread
+ *  * with added labels for the threadpool/thread name and thread no.
+ */
+public class ThreadScopedLongAdderCounter implements Counter {
+    private ThreadLocal<LongAdderCounter> counters;
+    private LongAdderCounter defaultCounter;
+    private Map<String, String> originalLabels;
+    private ScopeContext scopeContext;
+    private PrometheusMetricsProvider provider;
+
+    public ThreadScopedLongAdderCounter(PrometheusMetricsProvider provider,
+                                        ScopeContext scopeContext,
+                                        Map<String, String> labels) {
+        this.provider = provider;
+        this.scopeContext = scopeContext;
+        this.originalLabels = new HashMap<>(labels);
+        this.defaultCounter = new LongAdderCounter(labels);
+        Map<String, String> defaultLabels = new HashMap<>(labels);
+        defaultLabels.put("threadPool", "?");
+        defaultLabels.put("thread", "?");
+        this.defaultCounter.initializeThread(defaultLabels);
+
+        this.counters = ThreadLocal.withInitial(() -> {
+            return new LongAdderCounter(labels);
+        });
+    }
+
+    @Override
+    public void clear() {
+        getCounter().clear();
+    }
+
+    @Override
+    public void inc() {
+        getCounter().inc();
+    }
+
+    @Override
+    public void dec() {
+        getCounter().dec();
+    }
+
+    @Override
+    public void addCount(long delta) {
+        getCounter().addCount(delta);
+    }
+
+    @Override
+    public void addLatency(long eventLatency, TimeUnit unit) {
+        getCounter().addLatency(eventLatency, unit);
+    }
+
+    @Override
+    public Long get() {
+        return getCounter().get();
+    }
+
+    private LongAdderCounter getCounter() {
+        LongAdderCounter counter = counters.get();
+
+        // Lazy registration
+        // Update the counter with the thread labels then add to the provider
+        // If for some reason this thread did not get registered,
+        // then we fallback to a standard counter (defaultCounter)
+        if (!counter.isThreadInitialized()) {
+            ThreadRegistry.ThreadPoolThread tpt = ThreadRegistry.get();
+
+            if (tpt == null) {
+                counters.set(defaultCounter);
+                provider.counters.put(new ScopeContext(scopeContext.getScope(), originalLabels), defaultCounter);
+                return defaultCounter;
+            } else {
+                Map<String, String> threadScopedlabels = new HashMap<>(originalLabels);
+                threadScopedlabels.put("threadPool", tpt.getThreadPool());
+                threadScopedlabels.put("thread", String.valueOf(tpt.getOrdinal()));
+
+                counter.initializeThread(threadScopedlabels);
+                provider.counters.put(new ScopeContext(scopeContext.getScope(), threadScopedlabels), counter);
+            }
+        }
+
+        return counter;
+    }
+}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/package-info.java
similarity index 59%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/package-info.java
index 65cc10e0d3b..fd5cf0e154a 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/main/java/org/apache/pulsar/metrics/prometheus/bookkeeper/package-info.java
@@ -16,30 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
-
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
-
-public class MockAdditionalServlet implements AdditionalServlet {
-
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
-
-    }
-
-    @Override
-    public String getBasePath() {
-        return null;
-    }
-
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
-    }
-
-    @Override
-    public void close() {
-        // no-op
-    }
-}
+/**
+ * A stats provider implementation for BookKeeper that uses Jetty 12.
+ */
+package org.apache.pulsar.metrics.prometheus.bookkeeper;
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/test/java/org/apache/pulsar/metrics/prometheus/bookkeeper/TestPrometheusFormatter.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/test/java/org/apache/pulsar/metrics/prometheus/bookkeeper/TestPrometheusFormatter.java
new file mode 100644
index 00000000000..fceee727270
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/test/java/org/apache/pulsar/metrics/prometheus/bookkeeper/TestPrometheusFormatter.java
@@ -0,0 +1,296 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static org.testng.AssertJUnit.assertEquals;
+import static org.testng.AssertJUnit.assertTrue;
+import static org.testng.AssertJUnit.fail;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+import io.prometheus.client.CollectorRegistry;
+import io.prometheus.client.Gauge;
+import io.prometheus.client.hotspot.GarbageCollectorExports;
+import io.prometheus.client.hotspot.MemoryPoolsExports;
+import io.prometheus.client.hotspot.StandardExports;
+import io.prometheus.client.hotspot.ThreadExports;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.bookkeeper.stats.Counter;
+import org.apache.bookkeeper.stats.OpStatsLogger;
+import org.apache.bookkeeper.stats.StatsLogger;
+import org.testng.annotations.Test;
+
+/**
+ * Test for {@link PrometheusMetricsProvider}.
+ */
+public class TestPrometheusFormatter {
+
+    @Test(timeOut = 30000)
+    public void testStatsOutput() throws Exception {
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        StatsLogger statsLogger = provider.getStatsLogger("test");
+        Counter counter = statsLogger.getCounter("my_counter");
+
+        counter.inc();
+        counter.inc();
+
+        OpStatsLogger opStats = statsLogger.getOpStatsLogger("op");
+        opStats.registerSuccessfulEvent(10, TimeUnit.MILLISECONDS);
+        opStats.registerSuccessfulEvent(5, TimeUnit.MILLISECONDS);
+
+        OpStatsLogger opStats1 = statsLogger.scopeLabel("test_label", "test_value")
+                .getOpStatsLogger("op_label");
+        opStats1.registerSuccessfulEvent(10, TimeUnit.MILLISECONDS);
+        opStats1.registerSuccessfulEvent(5, TimeUnit.MILLISECONDS);
+        opStats1.registerFailedEvent(1, TimeUnit.MILLISECONDS);
+
+        provider.rotateLatencyCollection();
+
+        StringWriter writer = new StringWriter();
+        provider.writeAllMetrics(writer);
+
+        writer.write("jvm_memory_direct_bytes_max{} 4.77626368E8\n");
+        writer.write("jvm_memory_pool_bytes_used{pool=\"Code Cache\"} 3347712.0\n");
+        writer.write("jvm_memory_pool_bytes_used{pool=\"CodeHeap 'non-nmethods'\"} 1207168.0\n");
+        System.out.println(writer);
+        Multimap<String, Metric> metrics = parseMetrics(writer.toString());
+        System.out.println(metrics);
+
+        List<Metric> cm = (List<Metric>) metrics.get("test_my_counter");
+        assertEquals(1, cm.size());
+        assertEquals(0, cm.get(0).tags.size());
+        assertEquals(2.0, cm.get(0).value, 0.0);
+
+        // test_op_sum
+        cm = (List<Metric>) metrics.get("test_op_sum");
+        assertEquals(2, cm.size());
+        Metric m = cm.get(0);
+        assertEquals(1, cm.get(0).tags.size());
+        assertEquals(0.0, m.value, 0.0);
+        assertEquals(1, m.tags.size());
+        assertEquals("false", m.tags.get("success"));
+
+        m = cm.get(1);
+        assertEquals(1, cm.get(0).tags.size());
+        assertEquals(15.0, m.value, 0.0);
+        assertEquals(1, m.tags.size());
+        assertEquals("true", m.tags.get("success"));
+
+        // test_op_count
+        cm = (List<Metric>) metrics.get("test_op_count");
+        assertEquals(2, cm.size());
+        m = cm.get(0);
+        assertEquals(1, cm.get(0).tags.size());
+        assertEquals(0.0, m.value, 0.0);
+        assertEquals(1, m.tags.size());
+        assertEquals("false", m.tags.get("success"));
+
+        m = cm.get(1);
+        assertEquals(1, cm.get(0).tags.size());
+        assertEquals(2.0, m.value, 0.0);
+        assertEquals(1, m.tags.size());
+        assertEquals("true", m.tags.get("success"));
+
+        // Latency
+        cm = (List<Metric>) metrics.get("test_op");
+        assertEquals(14, cm.size());
+
+        boolean found = false;
+        for (Metric mt  : cm) {
+            if ("true".equals(mt.tags.get("success")) && "1.0".equals(mt.tags.get("quantile"))) {
+                assertEquals(10.0, mt.value, 0.0);
+                found = true;
+            }
+        }
+
+        assertTrue(found);
+
+        // test_op_label_sum
+        cm = (List<Metric>) metrics.get("test_op_label_sum");
+        assertEquals(2, cm.size());
+        m = cm.get(0);
+        assertEquals(2, m.tags.size());
+        assertEquals(1.0, m.value, 0.0);
+        assertEquals("false", m.tags.get("success"));
+        assertEquals("test_value", m.tags.get("test_label"));
+
+        m = cm.get(1);
+        assertEquals(15.0, m.value, 0.0);
+        assertEquals(2, m.tags.size());
+        assertEquals("true", m.tags.get("success"));
+        assertEquals("test_value", m.tags.get("test_label"));
+
+        // test_op_label_count
+        cm = (List<Metric>) metrics.get("test_op_label_count");
+        assertEquals(2, cm.size());
+        m = cm.get(0);
+        assertEquals(1, m.value, 0.0);
+        assertEquals(2, m.tags.size());
+        assertEquals("false", m.tags.get("success"));
+        assertEquals("test_value", m.tags.get("test_label"));
+
+        m = cm.get(1);
+        assertEquals(2.0, m.value, 0.0);
+        assertEquals(2, m.tags.size());
+        assertEquals("true", m.tags.get("success"));
+        assertEquals("test_value", m.tags.get("test_label"));
+
+        // Latency
+        cm = (List<Metric>) metrics.get("test_op_label");
+        assertEquals(14, cm.size());
+
+        found = false;
+        for (Metric mt : cm) {
+            if ("true".equals(mt.tags.get("success"))
+                    && "test_value".equals(mt.tags.get("test_label"))
+                    && "1.0".equals(mt.tags.get("quantile"))) {
+                assertEquals(10.0, mt.value, 0.0);
+                found = true;
+            }
+        }
+
+        assertTrue(found);
+    }
+
+    @Test
+    public void testWriteMetricsCollectedByPrometheusClient() {
+        CollectorRegistry registry = CollectorRegistry.defaultRegistry;
+        registry.register(new StandardExports());
+        registry.register(new MemoryPoolsExports());
+        registry.register(new GarbageCollectorExports());
+        registry.register(new ThreadExports());
+        registry.register(Gauge.build("jvm_memory_direct_bytes_used", "-").create().setChild(new Gauge.Child() {
+            @Override
+            public double get() {
+                return 1.0;
+            }
+        }));
+        registry.register(Gauge.build("jvm_memory_direct_bytes_max", "-").create().setChild(new Gauge.Child() {
+            @Override
+            public double get() {
+                return 100.0;
+            }
+        }));
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider(registry);
+        StringWriter writer = new StringWriter();
+        try {
+            provider.rotateLatencyCollection();
+            provider.writeAllMetrics(writer);
+            String output = writer.toString();
+            parseMetrics(output);
+            assertTrue(output.contains("# TYPE jvm_memory_direct_bytes_max gauge"));
+            assertTrue(output.contains("# TYPE jvm_memory_direct_bytes_used gauge"));
+            assertTrue(output.contains("# TYPE jvm_gc_collection_seconds summary"));
+            assertTrue(output.contains("# TYPE jvm_memory_pool_bytes_committed gauge"));
+            assertTrue(output.contains("# TYPE process_cpu_seconds counter"));
+        } catch (Exception e) {
+            fail();
+        }
+
+    }
+
+    @Test
+    public void testPrometheusTypeDuplicate() throws IOException {
+        PrometheusTextFormat prometheusTextFormat = new PrometheusTextFormat();
+        StringWriter writer = new StringWriter();
+        prometheusTextFormat.writeType(writer, "counter", "gauge");
+        prometheusTextFormat.writeType(writer, "counter", "gauge");
+        String string = writer.toString();
+        assertEquals("# TYPE counter gauge\n", string);
+    }
+
+
+    /**
+     * Hacky parsing of Prometheus text format. Sould be good enough for unit tests
+     */
+    private static Multimap<String, Metric> parseMetrics(String metrics) {
+        Multimap<String, Metric> parsed = ArrayListMultimap.create();
+
+        // Example of lines are
+        // jvm_threads_current{cluster="standalone",} 203.0
+        // or
+        // pulsar_subscriptions_count{cluster="standalone", namespace="sample/standalone/ns1",
+        // topic="persistent://sample/standalone/ns1/test-2"} 0.0 1517945780897
+        Pattern pattern = Pattern.compile("^(\\w+)(\\{([^\\}]*)\\})?\\s(-?[\\d\\w\\.]+)(\\s(\\d+))?$");
+        Pattern formatPattern =
+                Pattern.compile("^(\\w+)(\\{((\\w+=[-\\s\\\'\\\"\\.\\w]+(,\\s?\\w+=[\\\"\\.\\w]+)*))?\\})?"
+                        + "\\s(-?[\\d\\w\\.]+)(\\s(\\d+))?$");
+        Pattern tagsPattern = Pattern.compile("(\\w+)=\"([^\"]+)\"(,\\s?)?");
+
+        Splitter.on("\n").split(metrics).forEach(line -> {
+            if (line.isEmpty() || line.startsWith("#")) {
+                return;
+            }
+
+            System.err.println("LINE: '" + line + "'");
+            Matcher matcher = pattern.matcher(line);
+            Matcher formatMatcher = formatPattern.matcher(line);
+            System.err.println("Matches: " + matcher.matches());
+            System.err.println(matcher);
+            assertTrue(matcher.matches());
+            assertTrue("failed to validate line: " + line, formatMatcher.matches());
+
+            assertEquals(6, matcher.groupCount());
+            System.err.println("groups: " + matcher.groupCount());
+            for (int i = 0; i < matcher.groupCount(); i++) {
+                System.err.println("   GROUP " + i + " -- " + matcher.group(i));
+            }
+
+            checkArgument(matcher.matches());
+            checkArgument(formatMatcher.matches());
+            String name = matcher.group(1);
+
+            Metric m = new Metric();
+            m.value = Double.parseDouble(matcher.group(4));
+
+            String tags = matcher.group(3);
+            if (tags != null) {
+                Matcher tagsMatcher = tagsPattern.matcher(tags);
+                while (tagsMatcher.find()) {
+                    String tag = tagsMatcher.group(1);
+                    String value = tagsMatcher.group(2);
+                    m.tags.put(tag, value);
+                }
+            }
+
+            parsed.put(name, m);
+        });
+
+        return parsed;
+    }
+
+    static class Metric {
+        Map<String, String> tags = new TreeMap<>();
+        double value;
+
+        @Override
+        public String toString() {
+            return MoreObjects.toStringHelper(this).add("tags", tags).add("value", value).toString();
+        }
+    }
+}
diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/test/java/org/apache/pulsar/metrics/prometheus/bookkeeper/TestPrometheusMetricsProvider.java b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/test/java/org/apache/pulsar/metrics/prometheus/bookkeeper/TestPrometheusMetricsProvider.java
new file mode 100644
index 00000000000..a92616062c3
--- /dev/null
+++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/src/test/java/org/apache/pulsar/metrics/prometheus/bookkeeper/TestPrometheusMetricsProvider.java
@@ -0,0 +1,166 @@
+/*
+ * 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.pulsar.metrics.prometheus.bookkeeper;
+
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.AssertJUnit.assertEquals;
+import static org.testng.AssertJUnit.assertNotNull;
+import static org.testng.AssertJUnit.assertNull;
+import static org.testng.AssertJUnit.assertSame;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufAllocator;
+import java.io.StringWriter;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.concurrent.TimeUnit;
+import lombok.Cleanup;
+import org.apache.bookkeeper.stats.Counter;
+import org.apache.bookkeeper.stats.StatsLogger;
+import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.testng.annotations.Test;
+
+/**
+ * Unit test of {@link PrometheusMetricsProvider}.
+ */
+public class TestPrometheusMetricsProvider {
+
+    @Test
+    public void testStartNoHttp() {
+        PropertiesConfiguration config = new PropertiesConfiguration();
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_ENABLE, false);
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        try {
+            provider.start(config);
+            assertNull(provider.server);
+        } finally {
+            provider.stop();
+        }
+    }
+
+    @Test
+    public void testStartNoHttpWhenBkHttpEnabled() {
+        PropertiesConfiguration config = new PropertiesConfiguration();
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_ENABLE, true);
+        config.setProperty("httpServerEnabled", true);
+        @Cleanup("stop") PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        provider.start(config);
+        assertNull(provider.server);
+    }
+
+    @Test
+    public void testStartWithHttp() {
+        PropertiesConfiguration config = new PropertiesConfiguration();
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_ENABLE, true);
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_PORT, 0); // ephemeral
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        try {
+            provider.start(config);
+            assertNotNull(provider.server);
+        } finally {
+            provider.stop();
+        }
+    }
+
+    @Test
+    public void testStartWithHttpSpecifyAddr() {
+        PropertiesConfiguration config = new PropertiesConfiguration();
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_ENABLE, true);
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_PORT, 0); // ephemeral
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_ADDRESS, "127.0.0.1");
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        try {
+            provider.start(config);
+            assertNotNull(provider.server);
+        } finally {
+            provider.stop();
+        }
+    }
+
+    @Test
+    public void testCounter() {
+        LongAdderCounter counter = new LongAdderCounter(Collections.emptyMap());
+        long value = counter.get();
+        assertEquals(0L, value);
+        counter.inc();
+        assertEquals(1L, counter.get().longValue());
+        counter.dec();
+        assertEquals(0L, counter.get().longValue());
+        counter.addCount(3);
+        assertEquals(3L, counter.get().longValue());
+    }
+
+    @Test
+    public void testCounter2() {
+        LongAdderCounter counter = new LongAdderCounter(Collections.emptyMap());
+        long value = counter.get();
+        assertEquals(0L, value);
+        counter.addLatency(3 * 1000 * 1000L, TimeUnit.NANOSECONDS);
+        assertEquals(3L, counter.get().longValue());
+    }
+
+    @Test
+    public void testTwoCounters() throws Exception {
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        StatsLogger statsLogger =  provider.getStatsLogger("test");
+
+        Counter counter1 = statsLogger.getCounter("counter");
+        Counter counter2 = statsLogger.getCounter("counter");
+        assertEquals(counter1, counter2);
+        assertSame(counter1, counter2);
+
+        assertEquals(1, provider.counters.size());
+    }
+
+    @Test
+    public void testJvmDirectMemoryMetrics() throws Exception {
+        PropertiesConfiguration config = new PropertiesConfiguration();
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_ENABLE, true);
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_PORT, 0);
+        config.setProperty(PrometheusMetricsProvider.PROMETHEUS_STATS_HTTP_ADDRESS, "127.0.0.1");
+        ByteBuf byteBuf = ByteBufAllocator.DEFAULT.directBuffer(25);
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        try {
+            provider.start(config);
+            assertNotNull(provider.server);
+            StringWriter writer = new StringWriter();
+            provider.writeAllMetrics(writer);
+            String s = writer.toString();
+            String[] split = s.split(System.lineSeparator());
+            HashMap<String, String> map = new HashMap<>();
+            for (String str : split) {
+                String[] aux = str.split(" ");
+                map.put(aux[0], aux[1]);
+            }
+            String directBytesMax = map.get("jvm_memory_direct_bytes_max{}");
+            assertNotNull(directBytesMax);
+            assertNotEquals("Nan", directBytesMax);
+            assertNotEquals("-1", directBytesMax);
+            String directBytesUsed = map.get("jvm_memory_direct_bytes_used{}");
+            assertNotNull(directBytesUsed);
+            assertNotEquals("Nan", directBytesUsed);
+            // this condition is flaky
+            //assertTrue(Double.parseDouble(directBytesUsed) > 25);
+            // ensure byteBuffer doesn't gc
+            byteBuf.release();
+        } finally {
+            provider.stop();
+        }
+    }
+
+}
diff --git a/jetty-upgrade/pom.xml b/jetty-upgrade/pom.xml
new file mode 100644
index 00000000000..4898d78fc21
--- /dev/null
+++ b/jetty-upgrade/pom.xml
@@ -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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <packaging>pom</packaging>
+  <parent>
+    <groupId>org.apache.pulsar</groupId>
+    <artifactId>pulsar</artifactId>
+    <version>4.0.10-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>jetty-upgrade</artifactId>
+  <name>Pulsar Jetty Upgrade :: Parent</name>
+
+  <modules>
+    <module>bookkeeper-prometheus-metrics-provider</module>
+    <module>zookeeper-prometheus-metrics</module>
+    <module>zookeeper-with-patched-admin</module>
+  </modules>
+</project>
diff --git a/testmocks/pom.xml b/jetty-upgrade/zookeeper-prometheus-metrics/pom.xml
old mode 100644
new mode 100755
similarity index 52%
copy from testmocks/pom.xml
copy to jetty-upgrade/zookeeper-prometheus-metrics/pom.xml
index 505b8cb9609..63768761ce1
--- a/testmocks/pom.xml
+++ b/jetty-upgrade/zookeeper-prometheus-metrics/pom.xml
@@ -19,78 +19,52 @@
 
 -->
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
-
   <parent>
-    <artifactId>pulsar</artifactId>
     <groupId>org.apache.pulsar</groupId>
+    <artifactId>jetty-upgrade</artifactId>
     <version>4.0.10-SNAPSHOT</version>
   </parent>
 
-  <artifactId>testmocks</artifactId>
+  <artifactId>pulsar-zookeeper-prometheus-metrics</artifactId>
   <packaging>jar</packaging>
-  <name>Pulsar Test Mocks</name>
+  <name>Apache Pulsar :: Apache ZooKeeper Prometheus Metrics Provider</name>
+  <description>ZooKeeper Prometheus.io Metrics Provider implementation for Jetty 12</description>
 
   <dependencies>
-
     <dependency>
       <groupId>org.apache.zookeeper</groupId>
       <artifactId>zookeeper</artifactId>
-      <exclusions>
-        <exclusion>
-          <groupId>ch.qos.logback</groupId>
-          <artifactId>logback-core</artifactId>
-        </exclusion>
-        <exclusion>
-          <groupId>ch.qos.logback</groupId>
-          <artifactId>logback-classic</artifactId>
-        </exclusion>
-        <exclusion>
-          <groupId>io.netty</groupId>
-          <artifactId>netty-tcnative</artifactId>
-        </exclusion>
-      </exclusions>
+      <version>${zookeeper.version}</version>
+      <scope>provided</scope>
     </dependency>
-
     <dependency>
-      <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>bookkeeper-server</artifactId>
+      <groupId>io.prometheus</groupId>
+      <artifactId>simpleclient</artifactId>
     </dependency>
-
     <dependency>
-      <groupId>org.apache.commons</groupId>
-      <artifactId>commons-lang3</artifactId>
+      <groupId>io.prometheus</groupId>
+      <artifactId>simpleclient_hotspot</artifactId>
     </dependency>
-
     <dependency>
-      <groupId>org.testng</groupId>
-      <artifactId>testng</artifactId>
+      <groupId>io.prometheus</groupId>
+      <artifactId>simpleclient_servlet</artifactId>
     </dependency>
-
     <dependency>
-      <groupId>org.objenesis</groupId>
-      <artifactId>objenesis</artifactId>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-server</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty.ee8</groupId>
+      <artifactId>jetty-ee8-servlet</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
     </dependency>
-
   </dependencies>
-
-  <build>
-    <plugins>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-checkstyle-plugin</artifactId>
-        <executions>
-          <execution>
-            <id>checkstyle</id>
-            <phase>verify</phase>
-            <goals>
-              <goal>check</goal>
-            </goals>
-          </execution>
-        </executions>
-      </plugin>
-    </plugins>
-  </build>
-
 </project>
diff --git a/jetty-upgrade/zookeeper-prometheus-metrics/src/main/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProvider.java b/jetty-upgrade/zookeeper-prometheus-metrics/src/main/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProvider.java
new file mode 100644
index 00000000000..7b1999004ca
--- /dev/null
+++ b/jetty-upgrade/zookeeper-prometheus-metrics/src/main/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProvider.java
@@ -0,0 +1,632 @@
+/*
+ * 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.pulsar.metrics.prometheus.zookeeper;
+
+import io.prometheus.client.Collector;
+import io.prometheus.client.CollectorRegistry;
+import io.prometheus.client.exporter.MetricsServlet;
+import io.prometheus.client.hotspot.DefaultExports;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.Enumeration;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiConsumer;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.zookeeper.metrics.Counter;
+import org.apache.zookeeper.metrics.CounterSet;
+import org.apache.zookeeper.metrics.Gauge;
+import org.apache.zookeeper.metrics.GaugeSet;
+import org.apache.zookeeper.metrics.MetricsContext;
+import org.apache.zookeeper.metrics.MetricsProvider;
+import org.apache.zookeeper.metrics.MetricsProviderLifeCycleException;
+import org.apache.zookeeper.metrics.Summary;
+import org.apache.zookeeper.metrics.SummarySet;
+import org.apache.zookeeper.server.RateLogger;
+import org.eclipse.jetty.ee8.nested.ServletConstraint;
+import org.eclipse.jetty.ee8.security.ConstraintMapping;
+import org.eclipse.jetty.ee8.security.ConstraintSecurityHandler;
+import org.eclipse.jetty.ee8.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee8.servlet.ServletHolder;
+import org.eclipse.jetty.server.Server;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A Metrics Provider implementation based on https://prometheus.io.
+ *
+ * @since 3.6.0
+ */
+public class PrometheusMetricsProvider implements MetricsProvider {
+
+    private static final Logger LOG = LoggerFactory.getLogger(PrometheusMetricsProvider.class);
+    private static final String LABEL = "key";
+    private static final String[] LABELS = {LABEL};
+
+    /**
+     * Number of worker threads for reporting Prometheus summary metrics.
+     * Default value is 1.
+     * If the number is less than 1, the main thread will be used.
+     */
+    static final String NUM_WORKER_THREADS = "numWorkerThreads";
+
+    /**
+     * The max queue size for Prometheus summary metrics reporting task.
+     * Default value is 1000000.
+     */
+    static final String MAX_QUEUE_SIZE = "maxQueueSize";
+
+    /**
+     * The timeout in ms for Prometheus worker threads shutdown.
+     * Default value is 1000ms.
+     */
+    static final String WORKER_SHUTDOWN_TIMEOUT_MS = "workerShutdownTimeoutMs";
+
+    /**
+     * We are using the 'defaultRegistry'.
+     * <p>
+     * When you are running ZooKeeper (server or client) together with other
+     * libraries every metrics will be expected as a single view.
+     * </p>
+     */
+    private final CollectorRegistry collectorRegistry = CollectorRegistry.defaultRegistry;
+    private final RateLogger rateLogger = new RateLogger(LOG, 60 * 1000);
+    private String host = "0.0.0.0";
+    private int port = 7000;
+    private boolean exportJvmInfo = true;
+    private Server server;
+    private final MetricsServletImpl servlet = new MetricsServletImpl();
+    private final Context rootContext = new Context();
+    private int numWorkerThreads = 1;
+    private int maxQueueSize = 1000000;
+    private long workerShutdownTimeoutMs = 1000;
+    private Optional<ExecutorService> executorOptional = Optional.empty();
+
+    @Override
+    public void configure(Properties configuration) throws MetricsProviderLifeCycleException {
+        LOG.info("Initializing metrics, configuration: {}", configuration);
+        this.host = configuration.getProperty("httpHost", "0.0.0.0");
+        this.port = Integer.parseInt(configuration.getProperty("httpPort", "7000"));
+        this.exportJvmInfo = Boolean.parseBoolean(configuration.getProperty("exportJvmInfo", "true"));
+        this.numWorkerThreads = Integer.parseInt(
+                configuration.getProperty(NUM_WORKER_THREADS, "1"));
+        this.maxQueueSize = Integer.parseInt(
+                configuration.getProperty(MAX_QUEUE_SIZE, "1000000"));
+        this.workerShutdownTimeoutMs = Long.parseLong(
+                configuration.getProperty(WORKER_SHUTDOWN_TIMEOUT_MS, "1000"));
+    }
+
+    @Override
+    public void start() throws MetricsProviderLifeCycleException {
+        this.executorOptional = createExecutor();
+        try {
+            LOG.info("Starting /metrics HTTP endpoint at host: {}, port: {}, exportJvmInfo: {}",
+                    host, port, exportJvmInfo);
+            if (exportJvmInfo) {
+                DefaultExports.initialize();
+            }
+            server = new Server(new InetSocketAddress(host, port));
+            ServletContextHandler context = new ServletContextHandler();
+            context.setContextPath("/");
+            constrainTraceMethod(context);
+            server.setHandler(context);
+            context.addServlet(new ServletHolder(servlet), "/metrics");
+            server.start();
+        } catch (Exception err) {
+            LOG.error("Cannot start /metrics server", err);
+            if (server != null) {
+                try {
+                    server.stop();
+                } catch (Exception suppressed) {
+                    err.addSuppressed(suppressed);
+                } finally {
+                    server = null;
+                }
+            }
+            throw new MetricsProviderLifeCycleException(err);
+        }
+    }
+
+    // for tests
+    MetricsServletImpl getServlet() {
+        return servlet;
+    }
+
+    @Override
+    public MetricsContext getRootContext() {
+        return rootContext;
+    }
+
+    @Override
+    public void stop() {
+        shutdownExecutor();
+        if (server != null) {
+            try {
+                server.stop();
+            } catch (Exception err) {
+                LOG.error("Cannot safely stop Jetty server", err);
+            } finally {
+                server = null;
+            }
+        }
+    }
+
+    /**
+     * Dump all values to the 4lw interface and to the Admin server.
+     * <p>
+     * This method is not expected to be used to serve metrics to Prometheus. We
+     * are using the MetricsServlet provided by Prometheus for that, leaving the
+     * real representation to the Prometheus Java client.
+     * </p>
+     *
+     * @param sink the receiver of data (4lw interface, Admin server or tests)
+     */
+    @Override
+    public void dump(BiConsumer<String, Object> sink) {
+        sampleGauges();
+        Enumeration<Collector.MetricFamilySamples> samplesFamilies = collectorRegistry.metricFamilySamples();
+        while (samplesFamilies.hasMoreElements()) {
+            Collector.MetricFamilySamples samples = samplesFamilies.nextElement();
+            samples.samples.forEach(sample -> {
+                String key = buildKeyForDump(sample);
+                sink.accept(key, sample.value);
+            });
+        }
+    }
+
+    private static String buildKeyForDump(Collector.MetricFamilySamples.Sample sample) {
+        StringBuilder keyBuilder = new StringBuilder();
+        keyBuilder.append(sample.name);
+        if (sample.labelNames.size() > 0) {
+            keyBuilder.append('{');
+            for (int i = 0; i < sample.labelNames.size(); ++i) {
+                if (i > 0) {
+                    keyBuilder.append(',');
+                }
+                keyBuilder.append(sample.labelNames.get(i));
+                keyBuilder.append("=\"");
+                keyBuilder.append(sample.labelValues.get(i));
+                keyBuilder.append('"');
+            }
+            keyBuilder.append('}');
+        }
+        return keyBuilder.toString();
+    }
+
+    /**
+     * Update Gauges. In ZooKeeper Metrics API Gauges are callbacks served by
+     * internal components and the value is not held by Prometheus structures.
+     */
+    private void sampleGauges() {
+        rootContext.gauges.values()
+                .forEach(PrometheusGaugeWrapper::sample);
+
+        rootContext.gaugeSets.values()
+                .forEach(PrometheusLabelledGaugeWrapper::sample);
+    }
+
+    @Override
+    public void resetAllValues() {
+        // not supported on Prometheus
+    }
+
+    /**
+     * Add constraint to a given context to disallow TRACE method.
+     * @param ctxHandler the context to modify
+     */
+    private void constrainTraceMethod(ServletContextHandler ctxHandler) {
+        ServletConstraint c = new ServletConstraint();
+        c.setAuthenticate(true);
+
+        ConstraintMapping cmt = new ConstraintMapping();
+        cmt.setConstraint(c);
+        cmt.setMethod("TRACE");
+        cmt.setPathSpec("/*");
+
+        ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
+        securityHandler.setConstraintMappings(new ConstraintMapping[] {cmt});
+
+        ctxHandler.setSecurityHandler(securityHandler);
+    }
+
+    private class Context implements MetricsContext {
+
+        private final ConcurrentMap<String, PrometheusGaugeWrapper> gauges = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, PrometheusLabelledGaugeWrapper> gaugeSets = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, PrometheusCounter> counters = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, PrometheusLabelledCounter> counterSets = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, PrometheusSummary> basicSummaries = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, PrometheusSummary> summaries = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, PrometheusLabelledSummary> basicSummarySets = new ConcurrentHashMap<>();
+        private final ConcurrentMap<String, PrometheusLabelledSummary> summarySets = new ConcurrentHashMap<>();
+
+        @Override
+        public MetricsContext getContext(String name) {
+            // no hierarchy yet
+            return this;
+        }
+
+        @Override
+        public Counter getCounter(String name) {
+            return counters.computeIfAbsent(name, PrometheusCounter::new);
+        }
+
+        @Override
+        public CounterSet getCounterSet(final String name) {
+            Objects.requireNonNull(name, "Cannot register a CounterSet with null name");
+            return counterSets.computeIfAbsent(name, PrometheusLabelledCounter::new);
+        }
+
+        /**
+         * Gauges may go up and down, in ZooKeeper they are a way to export
+         * internal values with a callback.
+         *
+         * @param name  the name of the gauge
+         * @param gauge the callback
+         */
+        @Override
+        public void registerGauge(String name, Gauge gauge) {
+            Objects.requireNonNull(name);
+            gauges.compute(name, (id, prev) ->
+                    new PrometheusGaugeWrapper(id, gauge, prev != null ? prev.inner : null));
+        }
+
+        @Override
+        public void unregisterGauge(String name) {
+            PrometheusGaugeWrapper existing = gauges.remove(name);
+            if (existing != null) {
+                existing.unregister();
+            }
+        }
+
+        @Override
+        public void registerGaugeSet(final String name, final GaugeSet gaugeSet) {
+            Objects.requireNonNull(name, "Cannot register a GaugeSet with null name");
+            Objects.requireNonNull(gaugeSet, "Cannot register a null GaugeSet for " + name);
+
+            gaugeSets.compute(name, (id, prev) ->
+                new PrometheusLabelledGaugeWrapper(name, gaugeSet, prev != null ? prev.inner : null));
+        }
+
+        @Override
+        public void unregisterGaugeSet(final String name) {
+            Objects.requireNonNull(name, "Cannot unregister GaugeSet with null name");
+
+            final PrometheusLabelledGaugeWrapper existing = gaugeSets.remove(name);
+            if (existing != null) {
+                existing.unregister();
+            }
+        }
+
+        @Override
+        public Summary getSummary(String name, DetailLevel detailLevel) {
+            if (detailLevel == DetailLevel.BASIC) {
+                return basicSummaries.computeIfAbsent(name, (n) -> {
+                    if (summaries.containsKey(n)) {
+                        throw new IllegalArgumentException("Already registered a non basic summary as " + n);
+                    }
+                    return new PrometheusSummary(name, detailLevel);
+                });
+            } else {
+                return summaries.computeIfAbsent(name, (n) -> {
+                    if (basicSummaries.containsKey(n)) {
+                        throw new IllegalArgumentException("Already registered a basic summary as " + n);
+                    }
+                    return new PrometheusSummary(name, detailLevel);
+                });
+            }
+        }
+
+        @Override
+        public SummarySet getSummarySet(String name, DetailLevel detailLevel) {
+            if (detailLevel == DetailLevel.BASIC) {
+                return basicSummarySets.computeIfAbsent(name, (n) -> {
+                    if (summarySets.containsKey(n)) {
+                        throw new IllegalArgumentException("Already registered a non basic summary set as " + n);
+                    }
+                    return new PrometheusLabelledSummary(name, detailLevel);
+                });
+            } else {
+                return summarySets.computeIfAbsent(name, (n) -> {
+                    if (basicSummarySets.containsKey(n)) {
+                        throw new IllegalArgumentException("Already registered a basic summary set as " + n);
+                    }
+                    return new PrometheusLabelledSummary(name, detailLevel);
+                });
+            }
+        }
+
+    }
+
+    private class PrometheusCounter implements Counter {
+
+        private final io.prometheus.client.Counter inner;
+        private final String name;
+
+        public PrometheusCounter(String name) {
+            this.name = name;
+            this.inner = io.prometheus.client.Counter
+                    .build(name, name)
+                    .register(collectorRegistry);
+        }
+
+        @Override
+        public void add(long delta) {
+            try {
+                inner.inc(delta);
+            } catch (IllegalArgumentException err) {
+                LOG.error("invalid delta {} for metric {}", delta, name, err);
+            }
+        }
+
+        @Override
+        public long get() {
+            // this method is used only for tests
+            // Prometheus returns a "double"
+            // it is safe to fine to a long
+            // we are never setting non-integer values
+            return (long) inner.get();
+        }
+
+    }
+
+    private class PrometheusLabelledCounter implements CounterSet {
+        private final String name;
+        private final io.prometheus.client.Counter inner;
+
+        public PrometheusLabelledCounter(final String name) {
+            this.name = name;
+            this.inner = io.prometheus.client.Counter
+                    .build(name, name)
+                    .labelNames(LABELS)
+                    .register(collectorRegistry);
+        }
+
+        @Override
+        public void add(final String key, final long delta) {
+            try {
+                inner.labels(key).inc(delta);
+            } catch (final IllegalArgumentException e) {
+                LOG.error("invalid delta {} for metric {} with key {}", delta, name, key, e);
+            }
+        }
+    }
+
+    private class PrometheusGaugeWrapper {
+
+        private final io.prometheus.client.Gauge inner;
+        private final Gauge gauge;
+        private final String name;
+
+        public PrometheusGaugeWrapper(String name, Gauge gauge, io.prometheus.client.Gauge prev) {
+            this.name = name;
+            this.gauge = gauge;
+            this.inner = prev != null ? prev
+                    : io.prometheus.client.Gauge
+                    .build(name, name)
+                    .register(collectorRegistry);
+        }
+
+        /**
+         * Call the callack and update Prometheus Gauge. This method is called
+         * when the server is polling for a value.
+         */
+        private void sample() {
+            Number value = gauge.get();
+            this.inner.set(value != null ? value.doubleValue() : 0);
+        }
+
+        private void unregister() {
+            collectorRegistry.unregister(inner);
+        }
+    }
+
+    /**
+     * Prometheus implementation of GaugeSet interface. It wraps the GaugeSet object and
+     * uses the callback API to update the Prometheus Gauge.
+     */
+    private class PrometheusLabelledGaugeWrapper {
+        private final GaugeSet gaugeSet;
+        private final io.prometheus.client.Gauge inner;
+
+        private PrometheusLabelledGaugeWrapper(final String name,
+                                               final GaugeSet gaugeSet,
+                                               final io.prometheus.client.Gauge prev) {
+            this.gaugeSet = gaugeSet;
+            this.inner = prev != null ? prev :
+                    io.prometheus.client.Gauge
+                            .build(name, name)
+                            .labelNames(LABELS)
+                            .register(collectorRegistry);
+        }
+
+        /**
+         * Call the callback provided by the GaugeSet and update Prometheus Gauge.
+         * This method is called when the server is polling for a value.
+         */
+        private void sample() {
+            gaugeSet.values().forEach((key, value) ->
+                this.inner.labels(key).set(value != null ? value.doubleValue() : 0));
+        }
+
+        private void unregister() {
+            collectorRegistry.unregister(inner);
+        }
+    }
+
+    private class PrometheusSummary implements Summary {
+
+        private final io.prometheus.client.Summary inner;
+        private final String name;
+
+        public PrometheusSummary(String name, MetricsContext.DetailLevel level) {
+            this.name = name;
+            if (level == MetricsContext.DetailLevel.ADVANCED) {
+                this.inner = io.prometheus.client.Summary
+                        .build(name, name)
+                        .quantile(0.5, 0.05) // Add 50th percentile (= median) with 5% tolerated error
+                        .quantile(0.9, 0.01) // Add 90th percentile with 1% tolerated error
+                        .quantile(0.99, 0.001) // Add 99th percentile with 0.1% tolerated error
+                        .register(collectorRegistry);
+            } else {
+                this.inner = io.prometheus.client.Summary
+                        .build(name, name)
+                        .quantile(0.5, 0.05) // Add 50th percentile (= median) with 5% tolerated error
+                        .register(collectorRegistry);
+            }
+        }
+
+        @Override
+        public void add(long delta) {
+            reportMetrics(() -> observe(delta));
+        }
+
+        private void observe(final long delta) {
+            try {
+                inner.observe(delta);
+            } catch (final IllegalArgumentException err) {
+                LOG.error("invalid delta {} for metric {}", delta, name, err);
+            }
+        }
+    }
+
+    private class PrometheusLabelledSummary implements SummarySet {
+
+        private final io.prometheus.client.Summary inner;
+        private final String name;
+
+        public PrometheusLabelledSummary(String name, MetricsContext.DetailLevel level) {
+            this.name = name;
+            if (level == MetricsContext.DetailLevel.ADVANCED) {
+                this.inner = io.prometheus.client.Summary
+                        .build(name, name)
+                        .labelNames(LABELS)
+                        .quantile(0.5, 0.05) // Add 50th percentile (= median) with 5% tolerated error
+                        .quantile(0.9, 0.01) // Add 90th percentile with 1% tolerated error
+                        .quantile(0.99, 0.001) // Add 99th percentile with 0.1% tolerated error
+                        .register(collectorRegistry);
+            } else {
+                this.inner = io.prometheus.client.Summary
+                        .build(name, name)
+                        .labelNames(LABELS)
+                        .quantile(0.5, 0.05) // Add 50th percentile (= median) with 5% tolerated error
+                        .register(collectorRegistry);
+            }
+        }
+
+        @Override
+        public void add(String key, long value) {
+            reportMetrics(() -> observe(key, value));
+        }
+
+        private void observe(final String key, final long value) {
+            try {
+                inner.labels(key).observe(value);
+            } catch (final IllegalArgumentException err) {
+                LOG.error("invalid value {} for metric {} with key {}", value, name, key, err);
+            }
+        }
+
+    }
+
+    class MetricsServletImpl extends MetricsServlet {
+
+        @Override
+        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
+            // little trick: update the Gauges before serving data
+            // from Prometheus CollectorRegistry
+            sampleGauges();
+            // serve data using Prometheus built in client.
+            super.doGet(req, resp);
+        }
+    }
+
+    private Optional<ExecutorService> createExecutor() {
+        if (numWorkerThreads < 1) {
+            LOG.info("Executor service was not created as numWorkerThreads {} is less than 1", numWorkerThreads);
+            return Optional.empty();
+        }
+
+        final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(maxQueueSize);
+        final ThreadPoolExecutor executor = new ThreadPoolExecutor(numWorkerThreads,
+                numWorkerThreads,
+                0L,
+                TimeUnit.MILLISECONDS,
+                queue, new PrometheusWorkerThreadFactory());
+        LOG.info("Executor service was created with numWorkerThreads {} and maxQueueSize {}",
+                numWorkerThreads,
+                maxQueueSize);
+        return Optional.of(executor);
+    }
+
+    private void shutdownExecutor() {
+        if (executorOptional.isPresent()) {
+            LOG.info("Shutdown executor service with timeout {}", workerShutdownTimeoutMs);
+            final ExecutorService executor = executorOptional.get();
+            executor.shutdown();
+            try {
+                if (!executor.awaitTermination(workerShutdownTimeoutMs, TimeUnit.MILLISECONDS)) {
+                    LOG.error("Not all the Prometheus worker threads terminated properly after {} timeout",
+                            workerShutdownTimeoutMs);
+                    executor.shutdownNow();
+                }
+            } catch (final Exception e) {
+                LOG.error("Error occurred while terminating Prometheus worker threads", e);
+                executor.shutdownNow();
+            }
+        }
+    }
+
+    private static class PrometheusWorkerThreadFactory implements ThreadFactory {
+        private static final AtomicInteger workerCounter = new AtomicInteger(1);
+
+        @Override
+        public Thread newThread(final Runnable runnable) {
+            final String threadName = "PrometheusMetricsProviderWorker-" + workerCounter.getAndIncrement();
+            final Thread thread = new Thread(runnable, threadName);
+            thread.setDaemon(true);
+            return thread;
+        }
+    }
+
+    private void reportMetrics(final Runnable task) {
+        if (executorOptional.isPresent()) {
+            try {
+                executorOptional.get().submit(task);
+            } catch (final RejectedExecutionException e) {
+                rateLogger.rateLimitLog("Prometheus metrics reporting task queue size exceeded the max",
+                        String.valueOf(maxQueueSize));
+            }
+        } else {
+            task.run();
+        }
+    }
+}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/zookeeper-prometheus-metrics/src/main/java/org/apache/pulsar/metrics/prometheus/zookeeper/package-info.java
similarity index 59%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/zookeeper-prometheus-metrics/src/main/java/org/apache/pulsar/metrics/prometheus/zookeeper/package-info.java
index 65cc10e0d3b..59e2add5b97 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/zookeeper-prometheus-metrics/src/main/java/org/apache/pulsar/metrics/prometheus/zookeeper/package-info.java
@@ -16,30 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
-
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
-
-public class MockAdditionalServlet implements AdditionalServlet {
-
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
-
-    }
-
-    @Override
-    public String getBasePath() {
-        return null;
-    }
-
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
-    }
-
-    @Override
-    public void close() {
-        // no-op
-    }
-}
+/**
+ * Prometheus metrics provider for ZooKeeper that is using Jetty 12.
+ */
+package org.apache.pulsar.metrics.prometheus.zookeeper;
diff --git a/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/ExportJvmInfoTest.java b/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/ExportJvmInfoTest.java
new file mode 100644
index 00000000000..635fe1b0833
--- /dev/null
+++ b/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/ExportJvmInfoTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.pulsar.metrics.prometheus.zookeeper;
+
+import static org.testng.AssertJUnit.assertEquals;
+import io.prometheus.client.CollectorRegistry;
+import java.util.Properties;
+import org.testng.annotations.Test;
+
+/**
+ * Tests about Prometheus Metrics Provider. Please note that we are not testing
+ * Prometheus but our integration.
+ */
+public class ExportJvmInfoTest {
+
+    @Test
+    public void exportInfo() throws Exception {
+        runTest(true);
+    }
+
+    @Test
+    public void doNotExportInfo() throws Exception {
+        runTest(false);
+    }
+
+    private void runTest(boolean exportJvmInfo) throws Exception {
+        CollectorRegistry.defaultRegistry.clear();
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        try {
+            Properties configuration = new Properties();
+            configuration.setProperty("httpPort", "0"); // ephemeral port
+            configuration.setProperty("exportJvmInfo", exportJvmInfo + "");
+            provider.configure(configuration);
+            provider.start();
+            boolean[] found = {false};
+            provider.dump((k, v) -> {
+                found[0] = found[0] || k.contains("heap");
+            });
+            assertEquals(exportJvmInfo, found[0]);
+        } finally {
+            provider.stop();
+        }
+    }
+
+}
diff --git a/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProviderConfigTest.java b/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProviderConfigTest.java
new file mode 100644
index 00000000000..f076a15db93
--- /dev/null
+++ b/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProviderConfigTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.pulsar.metrics.prometheus.zookeeper;
+
+import io.prometheus.client.CollectorRegistry;
+import java.util.Properties;
+import org.apache.zookeeper.metrics.MetricsProviderLifeCycleException;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+
+public class PrometheusMetricsProviderConfigTest {
+
+    @Test
+    public void testInvalidPort() {
+        Assert.assertThrows(MetricsProviderLifeCycleException.class, () -> {
+            CollectorRegistry.defaultRegistry.clear();
+            PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+            Properties configuration = new Properties();
+            configuration.setProperty("httpPort", "65536");
+            configuration.setProperty("exportJvmInfo", "false");
+            provider.configure(configuration);
+            provider.start();
+        });
+    }
+
+    @Test
+    public void testInvalidAddr() {
+        Assert.assertThrows(MetricsProviderLifeCycleException.class, () -> {
+            CollectorRegistry.defaultRegistry.clear();
+            PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+            Properties configuration = new Properties();
+            configuration.setProperty("httpHost", "master");
+            provider.configure(configuration);
+            provider.start();
+        });
+    }
+
+    @Test
+    public void testValidConfig() throws MetricsProviderLifeCycleException {
+        CollectorRegistry.defaultRegistry.clear();
+        PrometheusMetricsProvider provider = new PrometheusMetricsProvider();
+        Properties configuration = new Properties();
+        configuration.setProperty("httpHost", "0.0.0.0");
+        configuration.setProperty("httpPort", "0");
+        provider.configure(configuration);
+        provider.start();
+    }
+
+}
diff --git a/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProviderTest.java b/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProviderTest.java
new file mode 100644
index 00000000000..55b993b3ab5
--- /dev/null
+++ b/jetty-upgrade/zookeeper-prometheus-metrics/src/test/java/org/apache/pulsar/metrics/prometheus/zookeeper/PrometheusMetricsProviderTest.java
@@ -0,0 +1,714 @@
+/*
+ * 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.pulsar.metrics.prometheus.zookeeper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertThrows;
+import static org.testng.AssertJUnit.assertEquals;
+import static org.testng.AssertJUnit.assertSame;
+import static org.testng.AssertJUnit.assertTrue;
+import static org.testng.AssertJUnit.fail;
+import io.prometheus.client.CollectorRegistry;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.zookeeper.metrics.Counter;
+import org.apache.zookeeper.metrics.CounterSet;
+import org.apache.zookeeper.metrics.Gauge;
+import org.apache.zookeeper.metrics.GaugeSet;
+import org.apache.zookeeper.metrics.MetricsContext;
+import org.apache.zookeeper.metrics.Summary;
+import org.apache.zookeeper.metrics.SummarySet;
+import org.apache.zookeeper.server.util.QuotaMetricsUtils;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+
+/**
+ * Tests about Prometheus Metrics Provider. Please note that we are not testing
+ * Prometheus but only our integration.
+ */
+public class PrometheusMetricsProviderTest {
+
+    private static final String URL_FORMAT = "http://localhost:%d/metrics";
+    private PrometheusMetricsProvider provider;
+
+    @BeforeMethod
+    public void setup() throws Exception {
+        CollectorRegistry.defaultRegistry.clear();
+        provider = new PrometheusMetricsProvider();
+        Properties configuration = new Properties();
+        configuration.setProperty("numWorkerThreads", "0"); // sync behavior for test
+        configuration.setProperty("httpHost", "127.0.0.1"); // local host for test
+        configuration.setProperty("httpPort", "0"); // ephemeral port
+        configuration.setProperty("exportJvmInfo", "false");
+        provider.configure(configuration);
+        provider.start();
+    }
+
+    @AfterMethod(alwaysRun = true)
+    public void tearDown() {
+        if (provider != null) {
+            provider.stop();
+        }
+        CollectorRegistry.defaultRegistry.clear();
+    }
+
+    @Test
+    public void testCounters() throws Exception {
+        Counter counter = provider.getRootContext().getCounter("cc");
+        counter.add(10);
+        int[] count = {0};
+        provider.dump((k, v) -> {
+                    if (k.contains("_created")) {
+                        return;
+                    }
+                    assertEquals("cc_total", k);
+                    assertEquals(10, ((Number) v).intValue());
+                    count[0]++;
+                }
+        );
+        assertEquals(1, count[0]);
+        count[0] = 0;
+
+        // this is not allowed but it must not throw errors
+        counter.add(-1);
+
+        provider.dump((k, v) -> {
+                    if (k.contains("_created")) {
+                        return;
+                    }
+                    assertEquals("cc_total", k);
+                    assertEquals(10, ((Number) v).intValue());
+                    count[0]++;
+                }
+        );
+        assertEquals(1, count[0]);
+
+        // we always must get the same object
+        assertSame(counter, provider.getRootContext().getCounter("cc"));
+
+        String res = callServlet();
+        assertThat(res).contains("# TYPE cc_total counter");
+        assertThat(res).contains("cc_total 10.0");
+    }
+
+    @Test
+    public void testCounterSet_single() throws Exception {
+        // create and register a CounterSet
+        final String name = QuotaMetricsUtils.QUOTA_EXCEEDED_ERROR_PER_NAMESPACE;
+        final CounterSet counterSet = provider.getRootContext().getCounterSet(name);
+        final String[] keys = {"ns1", "ns2"};
+        final int count = 3;
+
+        // update the CounterSet multiple times
+        for (int i = 0; i < count; i++) {
+            Arrays.asList(keys).forEach(key -> counterSet.inc(key));
+            Arrays.asList(keys).forEach(key -> counterSet.add(key, 2));
+        }
+
+        // validate with dump call
+        final Map<String, Number> expectedMetricsMap = new HashMap<>();
+        for (final String key : keys) {
+            expectedMetricsMap.put(String.format("%s_total{key=\"%s\"}", name, key), count * 3.0);
+        }
+        validateWithDump(expectedMetricsMap);
+
+        // validate with servlet call
+        final List<String> expectedNames = Collections.singletonList(String.format("# TYPE %s count", name));
+        final List<String> expectedMetrics = new ArrayList<>();
+        for (final String key : keys) {
+            expectedMetrics.add(String.format("%s{key=\"%s\",} %s", name, key, count * 3.0));
+        }
+        validateWithServletCall(expectedNames, expectedMetrics);
+
+        // validate registering with same name, no overwriting
+        assertSame(counterSet, provider.getRootContext().getCounterSet(name));
+    }
+
+    @Test
+    public void testCounterSet_multiple() throws Exception {
+        final String name = QuotaMetricsUtils.QUOTA_EXCEEDED_ERROR_PER_NAMESPACE;
+
+        final String[] names = new String[]{name + "_1", name + "_2"};
+        final String[] keys = new String[]{"ns21", "ns22"};
+        final int[] counts = new int[]{3, 5};
+
+        final int length = names.length;
+        final CounterSet[] counterSets = new CounterSet[length];
+
+        // create and register the CounterSets
+        for (int i = 0; i < length; i++) {
+            counterSets[i] = provider.getRootContext().getCounterSet(names[i]);
+        }
+
+        // update each CounterSet multiple times
+        for (int i = 0; i < length; i++) {
+            for (int j = 0; j < counts[i]; j++) {
+                counterSets[i].inc(keys[i]);
+            }
+        }
+
+        // validate with dump call
+        final Map<String, Number> expectedMetricsMap = new HashMap<>();
+        for (int i = 0; i < length; i++) {
+            expectedMetricsMap.put(String.format("%s_total{key=\"%s\"}", names[i], keys[i]), counts[i] * 1.0);
+        }
+        validateWithDump(expectedMetricsMap);
+
+        // validate with servlet call
+        final List<String> expectedNames = new ArrayList<>();
+        final List<String> expectedMetrics = new ArrayList<>();
+        for (int i = 0; i < length; i++) {
+            expectedNames.add(String.format("# TYPE %s count", names[i]));
+            expectedMetrics.add(String.format("%s{key=\"%s\",} %s", names[i], keys[i], counts[i] * 1.0));
+        }
+        validateWithServletCall(expectedNames, expectedMetrics);
+    }
+
+    @Test
+    public void testCounterSet_registerWithNullName() {
+        assertThrows(NullPointerException.class,
+                () -> provider.getRootContext().getCounterSet(null));
+    }
+
+    @Test
+    public void testCounterSet_negativeValue() {
+        // create and register a CounterSet
+        final String name = QuotaMetricsUtils.QUOTA_EXCEEDED_ERROR_PER_NAMESPACE;
+        final CounterSet counterSet = provider.getRootContext().getCounterSet(name);
+
+        // add negative value and make sure no exception is thrown
+        counterSet.add("ns1", -1);
+    }
+
+    @Test
+    public void testCounterSet_nullKey() {
+        // create and register a CounterSet
+        final String name = QuotaMetricsUtils.QUOTA_EXCEEDED_ERROR_PER_NAMESPACE;
+        final CounterSet counterSet = provider.getRootContext().getCounterSet(name);
+
+        // increment the count with null key and make sure no exception is thrown
+        counterSet.inc(null);
+        counterSet.add(null, 2);
+    }
+
+    @Test
+    public void testGauge() throws Exception {
+        int[] values = {78, -89};
+        int[] callCounts = {0, 0};
+        Gauge gauge0 = () -> {
+            callCounts[0]++;
+            return values[0];
+        };
+        Gauge gauge1 = () -> {
+            callCounts[1]++;
+            return values[1];
+        };
+        provider.getRootContext().registerGauge("gg", gauge0);
+
+        int[] count = {0};
+        provider.dump((k, v) -> {
+                    assertEquals("gg", k);
+                    assertEquals(values[0], ((Number) v).intValue());
+                    count[0]++;
+                }
+        );
+        assertEquals(1, callCounts[0]);
+        assertEquals(0, callCounts[1]);
+        assertEquals(1, count[0]);
+        count[0] = 0;
+        String res2 = callServlet();
+        assertThat(res2).contains("# TYPE gg gauge");
+        assertThat(res2.contains("gg 78.0"));
+
+        provider.getRootContext().unregisterGauge("gg");
+        provider.dump((k, v) -> {
+                    count[0]++;
+                }
+        );
+        assertEquals(2, callCounts[0]);
+        assertEquals(0, callCounts[1]);
+        assertEquals(0, count[0]);
+        String res3 = callServlet();
+        assertTrue(res3.isEmpty());
+
+        provider.getRootContext().registerGauge("gg", gauge1);
+
+        provider.dump((k, v) -> {
+                    assertEquals("gg", k);
+                    assertEquals(values[1], ((Number) v).intValue());
+                    count[0]++;
+                }
+        );
+        assertEquals(2, callCounts[0]);
+        assertEquals(1, callCounts[1]);
+        assertEquals(1, count[0]);
+        count[0] = 0;
+
+        String res4 = callServlet();
+        assertThat(res4.contains("# TYPE gg gauge"));
+        assertThat(res4.contains("gg -89.0"));
+        assertEquals(2, callCounts[0]);
+        // the servlet must sample the value again (from gauge1)
+        assertEquals(2, callCounts[1]);
+
+        // override gauge, without unregister
+        provider.getRootContext().registerGauge("gg", gauge0);
+
+        provider.dump((k, v) -> {
+                    count[0]++;
+                }
+        );
+        assertEquals(1, count[0]);
+        assertEquals(3, callCounts[0]);
+        assertEquals(2, callCounts[1]);
+    }
+
+    @Test
+    public void testBasicSummary() throws Exception {
+        Summary summary = provider.getRootContext()
+                .getSummary("cc", MetricsContext.DetailLevel.BASIC);
+        summary.add(10);
+        summary.add(10);
+        int[] count = {0};
+        provider.dump((k, v) -> {
+                    count[0]++;
+                    int value = ((Number) v).intValue();
+
+                    switch (k) {
+                        case "cc{quantile=\"0.5\"}":
+                            assertEquals(10, value);
+                            break;
+                        case "cc_count":
+                            assertEquals(2, value);
+                            break;
+                        case "cc_sum":
+                            assertEquals(20, value);
+                            break;
+                        case "cc_created":
+                            break;
+                        default:
+                            fail("unexpected key " + k);
+                            break;
+                    }
+                }
+        );
+        assertEquals(4, count[0]);
+        count[0] = 0;
+
+        // we always must get the same object
+        assertSame(summary, provider.getRootContext()
+                .getSummary("cc", MetricsContext.DetailLevel.BASIC));
+
+        try {
+            provider.getRootContext()
+                    .getSummary("cc", MetricsContext.DetailLevel.ADVANCED);
+            fail("Can't get the same summary with a different DetailLevel");
+        } catch (IllegalArgumentException err) {
+            assertThat(err.getMessage()).contains("Already registered");
+        }
+
+        String res = callServlet();
+        assertThat(res).contains("# TYPE cc summary");
+        assertThat(res).contains("cc_sum 20.0");
+        assertThat(res).contains("cc_count 2.0");
+        assertThat(res).contains("cc{quantile=\"0.5\",} 10.0");
+    }
+
+    @Test
+    public void testAdvancedSummary() throws Exception {
+        Summary summary = provider.getRootContext()
+                .getSummary("cc", MetricsContext.DetailLevel.ADVANCED);
+        summary.add(10);
+        summary.add(10);
+        int[] count = {0};
+        provider.dump((k, v) -> {
+                    count[0]++;
+                    int value = ((Number) v).intValue();
+
+                    switch (k) {
+                        case "cc{quantile=\"0.5\"}":
+                            assertEquals(10, value);
+                            break;
+                        case "cc{quantile=\"0.9\"}":
+                            assertEquals(10, value);
+                            break;
+                        case "cc{quantile=\"0.99\"}":
+                            assertEquals(10, value);
+                            break;
+                        case "cc_count":
+                            assertEquals(2, value);
+                            break;
+                        case "cc_sum":
+                            assertEquals(20, value);
+                            break;
+                        case "cc_created":
+                            break;
+                        default:
+                            fail("unexpected key " + k);
+                            break;
+                    }
+                }
+        );
+        assertEquals(6, count[0]);
+        count[0] = 0;
+
+        // we always must get the same object
+        assertSame(summary, provider.getRootContext()
+                .getSummary("cc", MetricsContext.DetailLevel.ADVANCED));
+
+        try {
+            provider.getRootContext()
+                    .getSummary("cc", MetricsContext.DetailLevel.BASIC);
+            fail("Can't get the same summary with a different DetailLevel");
+        } catch (IllegalArgumentException err) {
+            assertThat(err.getMessage()).contains("Already registered");
+        }
+
+        String res = callServlet();
+        assertThat(res).contains("# TYPE cc summary");
+        assertThat(res).contains("cc_sum 20.0");
+        assertThat(res).contains("cc_count 2.0");
+        assertThat(res).contains("cc{quantile=\"0.5\",} 10.0");
+        assertThat(res).contains("cc{quantile=\"0.9\",} 10.0");
+        assertThat(res).contains("cc{quantile=\"0.99\",} 10.0");
+    }
+
+    /**
+     * Using TRACE method to visit metrics provider, the response should be 403 forbidden.
+     */
+    @Test
+    public void testTraceCall() throws IOException, IllegalAccessException, NoSuchFieldException {
+        Field privateServerField = provider.getClass().getDeclaredField("server");
+        privateServerField.setAccessible(true);
+        Server server = (Server) privateServerField.get(provider);
+        int port = ((ServerConnector) server.getConnectors()[0]).getLocalPort();
+
+        String metricsUrl = String.format(URL_FORMAT, port);
+        HttpURLConnection conn = (HttpURLConnection) new URL(metricsUrl).openConnection();
+        conn.setRequestMethod("TRACE");
+        conn.connect();
+        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, conn.getResponseCode());
+    }
+
+    @Test
+    public void testSummary_asyncAndExceedMaxQueueSize() throws Exception {
+        final Properties config = new Properties();
+        config.setProperty("numWorkerThreads", "1");
+        config.setProperty("maxQueueSize", "1");
+        config.setProperty("httpPort", "0"); // ephemeral port
+        config.setProperty("exportJvmInfo", "false");
+
+        PrometheusMetricsProvider metricsProvider = null;
+        try {
+            metricsProvider = new PrometheusMetricsProvider();
+            metricsProvider.configure(config);
+            metricsProvider.start();
+            final Summary summary =
+                    metricsProvider.getRootContext().getSummary("cc", MetricsContext.DetailLevel.ADVANCED);
+
+            // make sure no error is thrown
+            for (int i = 0; i < 10; i++) {
+                summary.add(10);
+            }
+        } finally {
+            if (metricsProvider != null) {
+                metricsProvider.stop();
+            }
+        }
+    }
+
+    @Test
+    public void testSummarySet() throws Exception {
+        final String name = "ss";
+        final String[] keys = {"ns1", "ns2"};
+        final double count = 3.0;
+
+        // create and register a SummarySet
+        final SummarySet summarySet = provider.getRootContext()
+                .getSummarySet(name, MetricsContext.DetailLevel.BASIC);
+
+        // update the SummarySet multiple times
+        for (int i = 0; i < count; i++) {
+            Arrays.asList(keys).forEach(key -> summarySet.add(key, 1));
+        }
+
+        // validate with dump call
+        final Map<String, Number> expectedMetricsMap = new HashMap<>();
+        for (final String key : keys) {
+            expectedMetricsMap.put(String.format("%s{key=\"%s\",quantile=\"0.5\"}", name, key), 1.0);
+            expectedMetricsMap.put(String.format("%s_count{key=\"%s\"}", name, key), count);
+            expectedMetricsMap.put(String.format("%s_sum{key=\"%s\"}", name, key), count);
+        }
+        validateWithDump(expectedMetricsMap);
+
+        // validate with servlet call
+        final List<String> expectedNames = Collections.singletonList(String.format("# TYPE %s summary", name));
+        final List<String> expectedMetrics = new ArrayList<>();
+        for (final String key : keys) {
+            expectedMetrics.add(String.format("%s{key=\"%s\",quantile=\"0.5\",} %s", name, key, 1.0));
+            expectedMetrics.add(String.format("%s_count{key=\"%s\",} %s", name, key, count));
+            expectedMetrics.add(String.format("%s_sum{key=\"%s\",} %s", name, key, count));
+        }
+        validateWithServletCall(expectedNames, expectedMetrics);
+
+        // validate registering with same name, no overwriting
+        assertSame(summarySet, provider.getRootContext()
+                .getSummarySet(name, MetricsContext.DetailLevel.BASIC));
+
+        // validate registering with different DetailLevel, not allowed
+        try {
+            provider.getRootContext()
+                    .getSummarySet(name, MetricsContext.DetailLevel.ADVANCED);
+            fail("Can't get the same summarySet with a different DetailLevel");
+        } catch (final IllegalArgumentException e) {
+            assertThat(e.getMessage()).contains("Already registered");
+        }
+    }
+
+    private String callServlet() throws ServletException, IOException {
+        // we are not performing an HTTP request
+        // but we are calling directly the servlet
+        StringWriter writer = new StringWriter();
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        when(response.getWriter()).thenReturn(new PrintWriter(writer));
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        provider.getServlet().doGet(req, response);
+        String res = writer.toString();
+        return res;
+    }
+
+    @Test
+    public void testGaugeSet_singleGaugeSet() throws Exception {
+        final String name = QuotaMetricsUtils.QUOTA_BYTES_LIMIT_PER_NAMESPACE;
+        final Number[] values = {10.0, 100.0};
+        final String[] keys = {"ns11", "ns12"};
+        final Map<String, Number> metricsMap = new HashMap<>();
+        for (int i = 0; i < values.length; i++) {
+            metricsMap.put(keys[i], values[i]);
+        }
+        final AtomicInteger callCount = new AtomicInteger(0);
+
+        // create and register GaugeSet
+        createAndRegisterGaugeSet(name, metricsMap, callCount);
+
+        // validate with dump call
+        final Map<String, Number> expectedMetricsMap = new HashMap<>();
+        for (int i = 0; i < values.length; i++) {
+            expectedMetricsMap.put(String.format("%s{key=\"%s\"}", name, keys[i]), values[i]);
+        }
+        validateWithDump(expectedMetricsMap);
+        assertEquals(1, callCount.get());
+
+        // validate with servlet call
+        final List<String> expectedNames = Collections.singletonList(String.format("# TYPE %s gauge", name));
+        final List<String> expectedMetrics = new ArrayList<>();
+        for (int i = 0; i < values.length; i++) {
+            expectedMetrics.add(String.format("%s{key=\"%s\",} %s", name, keys[i], values[i]));
+        }
+        validateWithServletCall(expectedNames, expectedMetrics);
+        assertEquals(2, callCount.get());
+
+        // unregister the GaugeSet
+        callCount.set(0);
+        provider.getRootContext().unregisterGaugeSet(name);
+
+        // validate with dump call
+        validateWithDump(Collections.emptyMap());
+        assertEquals(0, callCount.get());
+
+        // validate with servlet call
+        validateWithServletCall(new ArrayList<>(), new ArrayList<>());
+        assertEquals(0, callCount.get());
+    }
+
+    @Test
+    public void testGaugeSet_multipleGaugeSets() throws Exception {
+        final String[] names = new String[]{
+                QuotaMetricsUtils.QUOTA_COUNT_LIMIT_PER_NAMESPACE,
+                QuotaMetricsUtils.QUOTA_COUNT_USAGE_PER_NAMESPACE
+        };
+
+        final Number[] values = new Number[]{20.0, 200.0};
+        final String[] keys = new String[]{"ns21", "ns22"};
+        final int count = names.length;
+        final AtomicInteger[] callCounts = new AtomicInteger[count];
+
+        // create and register the GaugeSets
+        for (int i = 0; i < count; i++) {
+            final Map<String, Number> metricsMap = new HashMap<>();
+            metricsMap.put(keys[i], values[i]);
+            callCounts[i] = new AtomicInteger(0);
+            createAndRegisterGaugeSet(names[i], metricsMap, callCounts[i]);
+        }
+
+        // validate with dump call
+        final Map<String, Number> expectedMetricsMap = new HashMap<>();
+        for (int i = 0; i < count; i++) {
+            expectedMetricsMap.put(String.format("%s{key=\"%s\"}", names[i], keys[i]), values[i]);
+        }
+        validateWithDump(expectedMetricsMap);
+        for (int i = 0; i < count; i++) {
+            assertEquals(1, callCounts[i].get());
+        }
+
+        // validate with servlet call
+        final List<String> expectedNames = new ArrayList<>();
+        final List<String> expectedMetrics = new ArrayList<>();
+        for (int i = 0; i < count; i++) {
+            expectedNames.add(String.format("# TYPE %s gauge", names[i]));
+            expectedMetrics.add(String.format("%s{key=\"%s\",} %s", names[i], keys[i], values[i]));
+        }
+        validateWithServletCall(expectedNames, expectedMetrics);
+        for (int i = 0; i < count; i++) {
+            assertEquals(2, callCounts[i].get());
+        }
+
+        // unregister the GaugeSets
+        for (int i = 0; i < count; i++) {
+            callCounts[i].set(0);
+            provider.getRootContext().unregisterGaugeSet(names[i]);
+        }
+
+        // validate with dump call
+        validateWithDump(Collections.emptyMap());
+        for (int i = 0; i < count; i++) {
+            assertEquals(0, callCounts[i].get());
+        }
+
+        // validate with servlet call
+        validateWithServletCall(new ArrayList<>(), new ArrayList<>());
+        for (int i = 0; i < count; i++) {
+            assertEquals(0, callCounts[i].get());
+        }
+    }
+
+    @Test
+    public void testGaugeSet_overwriteRegister() {
+        final String[] names = new String[]{
+                QuotaMetricsUtils.QUOTA_COUNT_LIMIT_PER_NAMESPACE,
+                QuotaMetricsUtils.QUOTA_COUNT_USAGE_PER_NAMESPACE
+        };
+
+        final int count = names.length;
+        final Number[] values = new Number[]{30.0, 300.0};
+        final String[] keys = new String[]{"ns31", "ns32"};
+        final AtomicInteger[] callCounts = new AtomicInteger[count];
+
+        // create and register the GaugeSets
+        for (int i = 0; i < count; i++) {
+            final Map<String, Number> metricsMap = new HashMap<>();
+            metricsMap.put(keys[i], values[i]);
+            callCounts[i] = new AtomicInteger(0);
+            // use the same name so the first GaugeSet got overwrite
+            createAndRegisterGaugeSet(names[0], metricsMap, callCounts[i]);
+        }
+
+        // validate with dump call to make sure the second GaugeSet overwrites the first
+        final Map<String, Number> expectedMetricsMap = new HashMap<>();
+        expectedMetricsMap.put(String.format("%s{key=\"%s\"}", names[0], keys[1]), values[1]);
+        validateWithDump(expectedMetricsMap);
+        assertEquals(0, callCounts[0].get());
+        assertEquals(1, callCounts[1].get());
+    }
+
+    @Test
+    public void testGaugeSet_nullKey() {
+        final String name = QuotaMetricsUtils.QUOTA_COUNT_LIMIT_PER_NAMESPACE;
+        final Map<String, Number> metricsMap = new HashMap<>();
+        metricsMap.put(null, 10.0);
+
+        final AtomicInteger callCount = new AtomicInteger(0);
+
+        // create and register GaugeSet
+        createAndRegisterGaugeSet(name, metricsMap, callCount);
+
+        // validate with dump call
+        assertThrows(IllegalArgumentException.class, () -> provider.dump(new HashMap<>()::put));
+
+        // validate with servlet call
+        assertThrows(IllegalArgumentException.class, this::callServlet);
+    }
+
+    @Test
+    public void testGaugeSet_registerWithNullGaugeSet() {
+        assertThrows(NullPointerException.class,
+                () -> provider.getRootContext().registerGaugeSet("name", null));
+
+        assertThrows(NullPointerException.class,
+                () -> provider.getRootContext().registerGaugeSet(null, HashMap::new));
+    }
+
+    @Test
+    public void testGaugeSet_unregisterNull() {
+        assertThrows(NullPointerException.class,
+                () -> provider.getRootContext().unregisterGaugeSet(null));
+    }
+
+    private void createAndRegisterGaugeSet(final String name,
+                                           final Map<String, Number> metricsMap,
+                                           final AtomicInteger callCount) {
+        final GaugeSet gaugeSet = () -> {
+            callCount.addAndGet(1);
+            return metricsMap;
+        };
+        provider.getRootContext().registerGaugeSet(name, gaugeSet);
+    }
+
+    private void validateWithDump(final Map<String, Number> expectedMetrics) {
+        final Map<String, Object> returnedMetrics = new HashMap<>();
+        provider.dump((key, value) -> {
+            if (!key.contains("_created{")) {
+                returnedMetrics.put(key, value);
+            }
+        });
+        assertThat(returnedMetrics).isEqualTo(expectedMetrics);
+    }
+
+    private void validateWithServletCall(final List<String> expectedNames,
+                                         final List<String> expectedMetrics) throws Exception {
+        final String response = callServlet();
+        if (expectedNames.isEmpty() && expectedMetrics.isEmpty()) {
+            assertTrue(response.isEmpty());
+        } else {
+            expectedNames.forEach(name -> assertThat(response.contains(name)));
+            expectedMetrics.forEach(metric -> assertThat(response.contains(metric)));
+        }
+    }
+}
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/pom.xml b/jetty-upgrade/zookeeper-with-patched-admin/pom.xml
new file mode 100644
index 00000000000..602db860d2e
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/pom.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.pulsar</groupId>
+    <artifactId>jetty-upgrade</artifactId>
+    <version>4.0.10-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>zookeeper-with-patched-admin</artifactId>
+  <name>Apache Pulsar :: patched Apache ZooKeeper with Admin API using Jetty 12.1.x</name>
+  <description>Apache ZooKeeper with patched admin servlet using Jetty 12.1.x API</description>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.zookeeper</groupId>
+      <artifactId>zookeeper</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.zookeeper</groupId>
+      <artifactId>zookeeper-jute</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-server</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty.ee8</groupId>
+      <artifactId>jetty-ee8-servlet</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.github.spotbugs</groupId>
+      <artifactId>spotbugs-annotations</artifactId>
+      <scope>provided</scope>
+      <optional>true</optional>
+    </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-databind</artifactId>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-shade-plugin</artifactId>
+        <executions>
+          <execution>
+            <phase>${shadePluginPhase}</phase>
+            <goals>
+              <goal>shade</goal>
+            </goals>
+            <configuration>
+              <createDependencyReducedPom>true</createDependencyReducedPom>
+              <useDependencyReducedPomInJar>true</useDependencyReducedPomInJar>
+              <promoteTransitiveDependencies>true</promoteTransitiveDependencies>
+              <artifactSet>
+                <includes>
+                  <include>org.apache.zookeeper:zookeeper</include>
+                </includes>
+              </artifactSet>
+              <filters>
+                <filter>
+                  <artifact>org.apache.zookeeper:zookeeper</artifact>
+                  <includes>
+                    <include>**</include>
+                  </includes>
+                  <excludes>
+                    <exclude>org/apache/zookeeper/server/admin/**</exclude>
+                  </excludes>
+                </filter>
+              </filters>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+  <profiles>
+    <profile>
+      <!-- Profile that is activated when using IntelliJ IDEA -->
+      <id>intellij</id>
+      <activation>
+        <property>
+          <name>idea.maven.embedder.version</name>
+        </property>
+      </activation>
+      <build>
+        <plugins>
+          <!-- Add shaded jar to artifacts for IntelliJ IDEA so that IntelliJ builds
+          and running tests work -->
+          <plugin>
+            <groupId>org.codehaus.mojo</groupId>
+            <artifactId>build-helper-maven-plugin</artifactId>
+            <executions>
+              <execution>
+                <id>attach-shade-jar</id>
+                <phase>package</phase>
+                <goals>
+                  <goal>attach-artifact</goal>
+                </goals>
+                <configuration>
+                  <artifacts>
+                    <artifact>
+                      <file>${project.build.directory}/${project.build.finalName}.jar</file>
+                      <type>jar</type>
+                      <classifier>shaded</classifier>
+                    </artifact>
+                  </artifacts>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+    <profile>
+      <id>eclipse</id>
+      <activation>
+        <property>
+          <name>m2e.version</name>
+        </property>
+      </activation>
+      <build>
+        <!-- skip shading and compilation in Eclipse/VSCode -->
+        <pluginManagement>
+          <plugins>
+            <plugin>
+              <groupId>org.apache.maven.plugins</groupId>
+              <artifactId>maven-shade-plugin</artifactId>
+              <configuration>
+                <skip>true</skip>
+              </configuration>
+            </plugin>
+            <plugin>
+              <groupId>org.apache.maven.plugins</groupId>
+              <artifactId>maven-compiler-plugin</artifactId>
+              <configuration>
+                <skip>true</skip>
+              </configuration>
+            </plugin>
+          </plugins>
+        </pluginManagement>
+      </build>
+    </profile>
+  </profiles>
+</project>
\ No newline at end of file
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AdminServer.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AdminServer.java
new file mode 100644
index 00000000000..095b4e0ddf0
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AdminServer.java
@@ -0,0 +1,53 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import org.apache.yetus.audience.InterfaceAudience;
+import org.apache.zookeeper.server.ZooKeeperServer;
+
+/**
+ * Interface for an embedded admin server that runs Commands. There is only one
+ * functional implementation, JettyAdminServer. DummyAdminServer, which does
+ * nothing, is used when we do not wish to run a server.
+ */
+@InterfaceAudience.Public
+public interface AdminServer {
+
+    void start() throws AdminServerException;
+
+    void shutdown() throws AdminServerException;
+
+    void setZooKeeperServer(ZooKeeperServer zkServer);
+
+    @InterfaceAudience.Public
+    class AdminServerException extends Exception {
+
+        private static final long serialVersionUID = 1L;
+
+        public AdminServerException(String message, Throwable cause) {
+            super(message, cause);
+        }
+
+        public AdminServerException(Throwable cause) {
+            super(cause);
+        }
+
+    }
+
+}
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AdminServerFactory.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AdminServerFactory.java
new file mode 100644
index 00000000000..cdf83486e69
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AdminServerFactory.java
@@ -0,0 +1,64 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import java.lang.reflect.InvocationTargetException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Factory class for creating an AdminServer.
+ */
+public class AdminServerFactory {
+
+    private static final Logger LOG = LoggerFactory.getLogger(AdminServerFactory.class);
+
+    /**
+     * This method encapsulates the logic for whether we should use a
+     * JettyAdminServer (i.e., the AdminServer is enabled) or a DummyAdminServer
+     * (i.e., the AdminServer is disabled). It uses reflection when attempting
+     * to create a JettyAdminServer, rather than referencing the class directly,
+     * so that it's ok to omit Jetty from the classpath if a user doesn't wish
+     * to pull in Jetty with ZooKeeper.
+     */
+    public static AdminServer createAdminServer() {
+        if (!"false".equals(System.getProperty("zookeeper.admin.enableServer"))) {
+            try {
+                Class<?> jettyAdminServerC = Class.forName("org.apache.zookeeper.server.admin.JettyAdminServer");
+                Object adminServer = jettyAdminServerC.getConstructor().newInstance();
+                return (AdminServer) adminServer;
+
+            } catch (ClassNotFoundException e) {
+                LOG.warn("Unable to start JettyAdminServer", e);
+            } catch (InstantiationException e) {
+                LOG.warn("Unable to start JettyAdminServer", e);
+            } catch (IllegalAccessException e) {
+                LOG.warn("Unable to start JettyAdminServer", e);
+            } catch (InvocationTargetException e) {
+                LOG.warn("Unable to start JettyAdminServer", e);
+            } catch (NoSuchMethodException e) {
+                LOG.warn("Unable to start JettyAdminServer", e);
+            } catch (NoClassDefFoundError e) {
+                LOG.warn("Unable to load jetty, not starting JettyAdminServer", e);
+            }
+        }
+        return new DummyAdminServer();
+    }
+
+}
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AuthRequest.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AuthRequest.java
new file mode 100644
index 00000000000..421d4f92931
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/AuthRequest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import org.apache.zookeeper.ZooDefs;
+
+/**
+ * Class representing auth data for performing ACL check on admin server commands.
+ *
+ * For example, SnapshotCommand requires {@link ZooDefs.Perms.ALL} permission on
+ * the root node.
+ *
+ */
+public class AuthRequest {
+    private final int permission;
+    private final String path;
+
+    /**
+     * @param permission the required permission for auth check
+     * @param path       the ZNode path for auth check
+     */
+    public AuthRequest(final int permission, final String path) {
+        this.permission = permission;
+        this.path = path;
+    }
+
+    /**
+     * @return permission
+     */
+    public int getPermission() {
+        return permission;
+    }
+
+    /**
+     * @return ZNode path
+     */
+    public String getPath() {
+        return path;
+    }
+
+    @Override
+    public String toString() {
+        return "AuthRequest{"
+                + "permission=" + permission
+                + ", path='" + path + '\''
+                + '}';
+    }
+}
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/Command.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/Command.java
new file mode 100644
index 00000000000..8a8089e2d60
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/Command.java
@@ -0,0 +1,90 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import java.io.InputStream;
+import java.util.Map;
+import java.util.Set;
+import org.apache.zookeeper.server.ZooKeeperServer;
+
+/**
+ * Interface implemented by all commands runnable by JettyAdminServer.
+ *
+ * @see CommandBase
+ * @see Commands
+ * @see JettyAdminServer
+ */
+public interface Command {
+
+    /**
+     * The set of all names that can be used to refer to this command (e.g.,
+     * "configuration", "config", and "conf").
+     */
+    Set<String> getNames();
+
+    /**
+     * The name that is returned with the command response and that appears in
+     * the list of all commands. This should be a member of the set returned by
+     * getNames().
+     */
+    String getPrimaryName();
+
+    /**
+     * @return true if the command requires an active ZooKeeperServer or a
+     * synced peer in order to resolve
+     */
+    boolean isServerRequired();
+
+    /**
+     * @return AuthRequest associated to the command. Null means auth check is not required.
+     */
+    AuthRequest getAuthRequest();
+
+    /**
+     * Run this command for HTTP GET request. Commands take a ZooKeeperServer, String-valued keyword
+     * arguments and return a CommandResponse object containing any information
+     * constituting the response to the command. Commands are responsible for
+     * parsing keyword arguments and performing any error handling if necessary.
+     * Errors should be reported by setting the "error" entry of the returned
+     * map with an appropriate message rather than throwing an exception.
+     *
+     * @param zkServer ZooKeeper server
+     * @param kwargs   keyword -&gt; argument value mapping
+     * @return CommandResponse representing response to command containing at minimum:
+     * - "command" key containing the command's primary name
+     * - "error" key containing a String error message or null if no error
+     */
+    CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs);
+
+    /**
+     * Run this command for HTTP POST. Commands take a ZooKeeperServer and InputStream and
+     * return a CommandResponse object containing any information
+     * constituting the response to the command. Commands are responsible for
+     * parsing keyword arguments and performing any error handling if necessary.
+     * Errors should be reported by setting the "error" entry of the returned
+     * map with an appropriate message rather than throwing an exception.
+     *
+     * @param zkServer    ZooKeeper server
+     * @param inputStream InputStream from request
+     * @return CommandResponse representing response to command containing at minimum:
+     * - "command" key containing the command's primary name
+     * - "error" key containing a String error message or null if no error
+     */
+    CommandResponse runPost(ZooKeeperServer zkServer, InputStream inputStream);
+}
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandBase.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandBase.java
new file mode 100644
index 00000000000..f6cb35066c3
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandBase.java
@@ -0,0 +1,83 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public abstract class CommandBase implements Command {
+
+    private final String primaryName;
+    private final Set<String> names;
+    private final boolean serverRequired;
+    private final AuthRequest authRequest;
+
+    /**
+     * @param names The possible names of this command, with the primary name first.
+     */
+    protected CommandBase(List<String> names) {
+        this(names, true);
+    }
+
+    protected CommandBase(List<String> names, boolean serverRequired) {
+        this(names, serverRequired, null);
+    }
+
+    protected CommandBase(List<String> names, boolean serverRequired, AuthRequest authRequest) {
+        if (authRequest != null && !serverRequired) {
+            throw new IllegalArgumentException("An active server is required for auth check");
+        }
+        this.primaryName = names.get(0);
+        this.names = new HashSet<>(names);
+        this.serverRequired = serverRequired;
+        this.authRequest = authRequest;
+    }
+
+    @Override
+    public String getPrimaryName() {
+        return primaryName;
+    }
+
+    @Override
+    public Set<String> getNames() {
+        return names;
+    }
+
+
+    @Override
+    public boolean isServerRequired() {
+        return serverRequired;
+    }
+
+    @Override
+    public AuthRequest getAuthRequest() {
+        return authRequest;
+    }
+
+    /**
+     * @return A response with the command set to the primary name and the
+     * error set to null (these are the two entries that all command
+     * responses are required to include).
+     */
+    protected CommandResponse initializeResponse() {
+        return new CommandResponse(primaryName);
+    }
+
+}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandOutputter.java
similarity index 57%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandOutputter.java
index 65cc10e0d3b..2571605c5d5 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandOutputter.java
@@ -16,30 +16,33 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
+package org.apache.zookeeper.server.admin;
 
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
+import java.io.OutputStream;
+import java.io.PrintWriter;
 
-public class MockAdditionalServlet implements AdditionalServlet {
-
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
-
-    }
+/**
+ * CommandOutputters are used to format the responses from Commands.
+ *
+ * @see Command
+ * @see JettyAdminServer
+ */
+public interface CommandOutputter {
 
-    @Override
-    public String getBasePath() {
-        return null;
-    }
+    /**
+     * The MIME type of this output (e.g., "application/json").
+     */
+    String getContentType();
 
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
+    /**
+     * Print out data as output.
+     */
+    default void output(CommandResponse response, PrintWriter pw) {
     }
 
-    @Override
-    public void close() {
-        // no-op
+    /**
+     * Stream out data as output.
+     */
+    default void output(final CommandResponse response, final OutputStream os) {
     }
 }
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandResponse.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandResponse.java
new file mode 100644
index 00000000000..35ba6707650
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/CommandResponse.java
@@ -0,0 +1,190 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * A response from running a {@link Command}.
+ */
+public class CommandResponse {
+
+    /**
+     * The key in the map returned by {@link #toMap()} for the command name.
+     */
+    public static final String KEY_COMMAND = "command";
+    /**
+     * The key in the map returned by {@link #toMap()} for the error string.
+     */
+    public static final String KEY_ERROR = "error";
+
+    private final String command;
+    private final String error;
+    private final Map<String, Object> data;
+    private final Map<String, String> headers;
+    private int statusCode;
+    private InputStream inputStream;
+
+    /**
+     * Creates a new response with no error string.
+     *
+     * @param command command name
+     */
+    public CommandResponse(String command) {
+        this(command, null, HttpServletResponse.SC_OK);
+    }
+
+    /**
+     * Creates a new response.
+     *
+     * @param command    command name
+     * @param error      error string (may be null)
+     * @param statusCode http status code
+     */
+    public CommandResponse(String command, String error, int statusCode) {
+        this(command, error, statusCode, null);
+    }
+
+
+    /**
+     * Creates a new response.
+     *
+     * @param command     command name
+     * @param error       error string (may be null)
+     * @param statusCode  http status code
+     * @param inputStream inputStream to send out data (may be null)
+     */
+    public CommandResponse(final String command, final String error, final int statusCode,
+                           final InputStream inputStream) {
+        this.command = command;
+        this.error = error;
+        data = new LinkedHashMap<>();
+        headers = new HashMap<>();
+        this.statusCode = statusCode;
+        this.inputStream = inputStream;
+    }
+
+    /**
+     * Gets the command name.
+     *
+     * @return command name
+     */
+    public String getCommand() {
+        return command;
+    }
+
+    /**
+     * Gets the error string (may be null).
+     *
+     * @return error string
+     */
+    public String getError() {
+        return error;
+    }
+
+    /**
+     * Gets the http status code.
+     *
+     * @return http status code
+     */
+    public int getStatusCode() {
+        return statusCode;
+    }
+
+    /**
+     * Sets the http status code.
+     */
+    public void setStatusCode(int statusCode) {
+        this.statusCode = statusCode;
+    }
+
+    /**
+     * Gets the InputStream (may be null).
+     *
+     * @return InputStream
+     */
+    public InputStream getInputStream() {
+        return inputStream;
+    }
+
+    /**
+     * Sets the InputStream.
+     */
+    public void setInputStream(final InputStream inputStream) {
+        this.inputStream = inputStream;
+    }
+
+    /**
+     * Adds a key/value pair to this response.
+     *
+     * @param key   key
+     * @param value value
+     * @return prior value for key, or null if none
+     */
+    public Object put(String key, Object value) {
+        return data.put(key, value);
+    }
+
+    /**
+     * Adds all key/value pairs in the given map to this response.
+     *
+     * @param m map of key/value pairs
+     */
+    public void putAll(Map<? extends String, ?> m) {
+        data.putAll(m);
+    }
+
+    /**
+     * Adds a header to this response.
+     *
+     * @param name  name of the header
+     * @param value value of the header
+     */
+    public void addHeader(final String name, final String value) {
+        headers.put(name, value);
+    }
+
+    /**
+     * Returns all headers.
+     *
+     * @return map representation of all headers
+     */
+    public Map<String, String> getHeaders() {
+        return headers;
+    }
+
+    /**
+     * Converts this response to a map. The returned map is mutable, and
+     * changes to it do not reflect back into this response.
+     *
+     * @return map representation of response
+     */
+    public Map<String, Object> toMap() {
+        Map<String, Object> m = new LinkedHashMap<>(data);
+        m.put(KEY_COMMAND, command);
+        m.put(KEY_ERROR, error);
+        m.putAll(data);
+        return m;
+    }
+
+}
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/Commands.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/Commands.java
new file mode 100644
index 00000000000..2b4b89a422f
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/Commands.java
@@ -0,0 +1,1168 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import static org.apache.zookeeper.server.persistence.FileSnap.SNAPSHOT_FILE_PREFIX;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.zookeeper.Environment;
+import org.apache.zookeeper.Environment.Entry;
+import org.apache.zookeeper.KeeperException;
+import org.apache.zookeeper.Version;
+import org.apache.zookeeper.ZooDefs;
+import org.apache.zookeeper.data.ACL;
+import org.apache.zookeeper.data.Id;
+import org.apache.zookeeper.server.DataNode;
+import org.apache.zookeeper.server.DataTree;
+import org.apache.zookeeper.server.ServerCnxnFactory;
+import org.apache.zookeeper.server.ServerMetrics;
+import org.apache.zookeeper.server.ZooKeeperServer;
+import org.apache.zookeeper.server.ZooTrace;
+import org.apache.zookeeper.server.auth.ProviderRegistry;
+import org.apache.zookeeper.server.auth.ServerAuthenticationProvider;
+import org.apache.zookeeper.server.persistence.SnapshotInfo;
+import org.apache.zookeeper.server.persistence.Util;
+import org.apache.zookeeper.server.quorum.Follower;
+import org.apache.zookeeper.server.quorum.FollowerZooKeeperServer;
+import org.apache.zookeeper.server.quorum.Leader;
+import org.apache.zookeeper.server.quorum.LeaderZooKeeperServer;
+import org.apache.zookeeper.server.quorum.MultipleAddresses;
+import org.apache.zookeeper.server.quorum.QuorumPeer;
+import org.apache.zookeeper.server.quorum.QuorumPeer.LearnerType;
+import org.apache.zookeeper.server.quorum.QuorumZooKeeperServer;
+import org.apache.zookeeper.server.quorum.ReadOnlyZooKeeperServer;
+import org.apache.zookeeper.server.quorum.flexible.QuorumVerifier;
+import org.apache.zookeeper.server.util.RateLimiter;
+import org.apache.zookeeper.server.util.ZxidUtils;
+import org.eclipse.jetty.http.HttpStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class containing static methods for registering and running Commands, as well
+ * as default Command definitions.
+ *
+ * @see Command
+ * @see JettyAdminServer
+ */
+public class Commands {
+
+    static final Logger LOG = LoggerFactory.getLogger(Commands.class);
+    // VisibleForTesting
+    static final String ADMIN_RATE_LIMITER_INTERVAL = "zookeeper.admin.rateLimiterIntervalInMS";
+    private static final long rateLimiterInterval =
+            Integer.parseInt(System.getProperty(ADMIN_RATE_LIMITER_INTERVAL, "300000"));
+    // VisibleForTesting
+    static final String AUTH_INFO_SEPARATOR = " ";
+    // VisibleForTesting
+    static final String ROOT_PATH = "/";
+
+    /**
+     * Maps command names to Command instances.
+     */
+    private static Map<String, Command> commands = new HashMap<>();
+    private static Set<String> primaryNames = new HashSet<>();
+
+    /**
+     * Registers the given command. Registered commands can be run by passing
+     * any of their names to runCommand.
+     */
+    public static void registerCommand(Command command) {
+        for (String name : command.getNames()) {
+            Command prev = commands.put(name, command);
+            if (prev != null) {
+                LOG.warn("Re-registering command {} (primary name = {})", name, command.getPrimaryName());
+            }
+        }
+        primaryNames.add(command.getPrimaryName());
+    }
+
+    /**
+     * Run the registered command with name cmdName. Commands should not produce
+     * any exceptions; any (anticipated) errors should be reported in the
+     * "error" entry of the returned map. Likewise, if no command with the given
+     * name is registered, this will be noted in the "error" entry.
+     *
+     * @param cmdName
+     * @param zkServer
+     * @param kwargs   String-valued keyword arguments to the command from HTTP GET request
+     *                 (may be null if command requires no additional arguments)
+     * @param authInfo auth info for auth check
+     *                 (null if command requires no auth check)
+     * @param request  HTTP request
+     * @return Map representing response to command containing at minimum:
+     * - "command" key containing the command's primary name
+     * - "error" key containing a String error message or null if no error
+     */
+    public static CommandResponse runGetCommand(
+            String cmdName,
+            ZooKeeperServer zkServer,
+            Map<String, String> kwargs,
+            String authInfo,
+            HttpServletRequest request) {
+        return runCommand(cmdName, zkServer, kwargs, null, authInfo, request, true);
+    }
+
+    /**
+     * Run the registered command with name cmdName. Commands should not produce
+     * any exceptions; any (anticipated) errors should be reported in the
+     * "error" entry of the returned map. Likewise, if no command with the given
+     * name is registered, this will be noted in the "error" entry.
+     *
+     * @param cmdName
+     * @param zkServer
+     * @param inputStream InputStream from HTTP POST request
+     * @return Map representing response to command containing at minimum:
+     * - "command" key containing the command's primary name
+     * - "error" key containing a String error message or null if no error
+     */
+    public static CommandResponse runPostCommand(
+            String cmdName,
+            ZooKeeperServer zkServer,
+            InputStream inputStream,
+            String authInfo,
+            HttpServletRequest request) {
+        return runCommand(cmdName, zkServer, null, inputStream, authInfo, request, false);
+    }
+
+    private static CommandResponse runCommand(
+            String cmdName,
+            ZooKeeperServer zkServer,
+            Map<String, String> kwargs,
+            InputStream inputStream,
+            String authInfo,
+            HttpServletRequest request,
+            boolean isGet) {
+        Command command = getCommand(cmdName);
+        if (command == null) {
+            // set the status code to 200 to keep the current behavior of existing commands
+            LOG.warn("Unknown command");
+            return new CommandResponse(cmdName, "Unknown command: " + cmdName, HttpServletResponse.SC_OK);
+        }
+        if (command.isServerRequired() && (zkServer == null || !zkServer.isRunning())) {
+            // set the status code to 200 to keep the current behavior of existing commands
+            LOG.warn("This ZooKeeper instance is not currently serving requests for command");
+            return new CommandResponse(cmdName, "This ZooKeeper instance is not currently serving requests",
+                    HttpServletResponse.SC_OK);
+        }
+
+        final AuthRequest authRequest = command.getAuthRequest();
+        if (authRequest != null) {
+            if (authInfo == null) {
+                LOG.warn("Auth info is missing for command");
+                return new CommandResponse(cmdName, "Auth info is missing for the command",
+                        HttpServletResponse.SC_UNAUTHORIZED);
+            }
+            try {
+                final List<Id> ids = handleAuthentication(request, authInfo);
+                handleAuthorization(zkServer, ids, authRequest.getPermission(), authRequest.getPath());
+            } catch (final KeeperException.AuthFailedException e) {
+                return new CommandResponse(cmdName, "Not authenticated", HttpServletResponse.SC_UNAUTHORIZED);
+            } catch (final KeeperException.NoAuthException e) {
+                return new CommandResponse(cmdName, "Not authorized", HttpServletResponse.SC_FORBIDDEN);
+            } catch (final Exception e) {
+                LOG.warn("Error occurred during auth for command", e);
+                return new CommandResponse(cmdName, "Error occurred during auth",
+                        HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+            }
+        }
+        return isGet ? command.runGet(zkServer, kwargs) : command.runPost(zkServer, inputStream);
+    }
+
+    private static List<Id> handleAuthentication(final HttpServletRequest request, final String authInfo)
+            throws KeeperException.AuthFailedException {
+        final String[] authData = authInfo.split(AUTH_INFO_SEPARATOR);
+        // for IP and x509, auth info only contains the schema and Auth Id will be extracted from HTTP request
+        if (authData.length != 1 && authData.length != 2) {
+            LOG.warn("Invalid auth info length");
+            throw new KeeperException.AuthFailedException();
+        }
+
+        final String schema = authData[0];
+        final ServerAuthenticationProvider authProvider = ProviderRegistry.getServerProvider(schema);
+        if (authProvider != null) {
+            try {
+                final byte[] auth = authData.length == 2 ? authData[1].getBytes(StandardCharsets.UTF_8) : null;
+                final List<Id> ids = authProvider.handleAuthentication(request, auth);
+                if (ids.isEmpty()) {
+                    LOG.warn("Auth Id list is empty");
+                    throw new KeeperException.AuthFailedException();
+                }
+                return ids;
+            } catch (final RuntimeException e) {
+                LOG.warn("Caught runtime exception from AuthenticationProvider", e);
+                throw new KeeperException.AuthFailedException();
+            }
+        } else {
+            LOG.warn("Auth provider not found for schema");
+            throw new KeeperException.AuthFailedException();
+        }
+    }
+
+    /**
+     * Grant or deny authorization for a command by matching
+     * request-provided credentials with the ACLs present on a node.
+     *
+     * @param zkServer the ZooKeeper server object.
+     * @param ids      the credentials extracted from the Authorization header.
+     * @param perm     the set of permission bits required by the command.
+     * @param path     the ZooKeeper node path whose ACLs should be used
+     *                 to satisfy the perm bits.
+     * @throws KeeperException.NoAuthException if one or more perm
+     *                                         bits could not be satisfied.
+     */
+    private static void handleAuthorization(final ZooKeeperServer zkServer,
+                                            final List<Id> ids,
+                                            final int perm,
+                                            final String path)
+            throws KeeperException.NoNodeException, KeeperException.NoAuthException {
+        final DataNode dataNode = zkServer.getZKDatabase().getNode(path);
+        if (dataNode == null) {
+            throw new KeeperException.NoNodeException(path);
+        }
+        final List<ACL> acls = zkServer.getZKDatabase().aclForNode(dataNode);
+        // Check the individual bits of perm.
+        final int bitWidth = Integer.SIZE - Integer.numberOfLeadingZeros(perm);
+        for (int b = 0; b < bitWidth; b++) {
+            final int permBit = 1 << b;
+            if ((perm & permBit) != 0) {
+                zkServer.checkACL(null, acls, permBit, ids, path, null);
+            }
+        }
+    }
+
+    /**
+     * Returns the primary names of all registered commands.
+     */
+    public static Set<String> getPrimaryNames() {
+        return primaryNames;
+    }
+
+    /**
+     * Returns the commands registered under cmdName with registerCommand, or
+     * null if no command is registered with that name.
+     */
+    public static Command getCommand(String cmdName) {
+        return commands.get(cmdName);
+    }
+
+    static {
+        registerCommand(new CnxnStatResetCommand());
+        registerCommand(new ConfCommand());
+        registerCommand(new ConsCommand());
+        registerCommand(new DigestCommand());
+        registerCommand(new DirsCommand());
+        registerCommand(new DumpCommand());
+        registerCommand(new EnvCommand());
+        registerCommand(new GetTraceMaskCommand());
+        registerCommand(new InitialConfigurationCommand());
+        registerCommand(new IsroCommand());
+        registerCommand(new LastSnapshotCommand());
+        registerCommand(new LeaderCommand());
+        registerCommand(new MonitorCommand());
+        registerCommand(new ObserverCnxnStatResetCommand());
+        registerCommand(new RestoreCommand());
+        registerCommand(new RuokCommand());
+        registerCommand(new SetTraceMaskCommand());
+        registerCommand(new SnapshotCommand());
+        registerCommand(new SrvrCommand());
+        registerCommand(new StatCommand());
+        registerCommand(new StatResetCommand());
+        registerCommand(new SyncedObserverConsCommand());
+        registerCommand(new SystemPropertiesCommand());
+        registerCommand(new VotingViewCommand());
+        registerCommand(new WatchCommand());
+        registerCommand(new WatchesByPathCommand());
+        registerCommand(new WatchSummaryCommand());
+        registerCommand(new ZabStateCommand());
+    }
+
+    /**
+     * Reset all connection statistics.
+     */
+    public static class CnxnStatResetCommand extends GetCommand {
+
+        public CnxnStatResetCommand() {
+            super(Arrays.asList("connection_stat_reset", "crst"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            zkServer.getServerCnxnFactory().resetAllConnectionStats();
+            return response;
+
+        }
+
+    }
+
+    /**
+     * Server configuration parameters.
+     *
+     * @see ZooKeeperServer#getConf()
+     */
+    public static class ConfCommand extends GetCommand {
+
+        public ConfCommand() {
+            super(Arrays.asList("configuration", "conf", "config"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            response.putAll(zkServer.getConf().toMap());
+            return response;
+        }
+
+    }
+
+    /**
+     * Information on client connections to server. Returned Map contains:
+     * - "connections": list of connection info objects
+     *
+     * @see org.apache.zookeeper.server.ServerCnxn#getConnectionInfo(boolean)
+     */
+    public static class ConsCommand extends GetCommand {
+
+        public ConsCommand() {
+            super(Arrays.asList("connections", "cons"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            ServerCnxnFactory serverCnxnFactory = zkServer.getServerCnxnFactory();
+            if (serverCnxnFactory != null) {
+                response.put("connections", serverCnxnFactory.getAllConnectionInfo(false));
+            } else {
+                response.put("connections", Collections.emptyList());
+            }
+            ServerCnxnFactory secureServerCnxnFactory = zkServer.getSecureServerCnxnFactory();
+            if (secureServerCnxnFactory != null) {
+                response.put("secure_connections", secureServerCnxnFactory.getAllConnectionInfo(false));
+            } else {
+                response.put("secure_connections", Collections.emptyList());
+            }
+            return response;
+        }
+
+    }
+
+    /**
+     * Information on ZK datadir and snapdir size in bytes.
+     */
+    public static class DirsCommand extends GetCommand {
+
+        public DirsCommand() {
+            super(Arrays.asList("dirs"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            response.put("datadir_size", zkServer.getDataDirSize());
+            response.put("logdir_size", zkServer.getLogDirSize());
+            return response;
+        }
+
+    }
+
+    /**
+     * Information on session expirations and ephemerals. Returned map contains:
+     * - "expiry_time_to_session_ids": Map&lt;Long, Set&lt;Long&gt;&gt;
+     * time -&gt; sessions IDs of sessions that expire at time
+     * - "session_id_to_ephemeral_paths": Map&lt;Long, Set&lt;String&gt;&gt;
+     * session ID -&gt; ephemeral paths created by that session
+     *
+     * @see ZooKeeperServer#getSessionExpiryMap()
+     * @see ZooKeeperServer#getEphemerals()
+     */
+    public static class DumpCommand extends GetCommand {
+
+        public DumpCommand() {
+            super(Arrays.asList("dump"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            response.put("expiry_time_to_session_ids", zkServer.getSessionExpiryMap());
+            response.put("session_id_to_ephemeral_paths", zkServer.getEphemerals());
+            return response;
+        }
+
+    }
+
+    /**
+     * All defined environment variables.
+     */
+    public static class EnvCommand extends GetCommand {
+
+        public EnvCommand() {
+            super(Arrays.asList("environment", "env", "envi"), false);
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            for (Entry e : Environment.list()) {
+                response.put(e.getKey(), e.getValue());
+            }
+            return response;
+        }
+
+    }
+
+    /**
+     * Digest histories for every specific number of txns.
+     */
+    public static class DigestCommand extends GetCommand {
+
+        public DigestCommand() {
+            super(Arrays.asList("hash"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            response.put("digests", zkServer.getZKDatabase().getDataTree().getDigestLog());
+            return response;
+        }
+
+    }
+
+    /**
+     * The current trace mask. Returned map contains:
+     * - "tracemask": Long
+     */
+    public static class GetTraceMaskCommand extends GetCommand {
+
+        public GetTraceMaskCommand() {
+            super(Arrays.asList("get_trace_mask", "gtmk"), false);
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            response.put("tracemask", ZooTrace.getTextTraceLevel());
+            return response;
+        }
+
+    }
+
+    public static class InitialConfigurationCommand extends GetCommand {
+
+        public InitialConfigurationCommand() {
+            super(Arrays.asList("initial_configuration", "icfg"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            response.put("initial_configuration", zkServer.getInitialConfig());
+            return response;
+        }
+
+    }
+
+    /**
+     * Is this server in read-only mode. Returned map contains:
+     * - "is_read_only": Boolean
+     */
+    public static class IsroCommand extends GetCommand {
+
+        public IsroCommand() {
+            super(Arrays.asList("is_read_only", "isro"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            response.put("read_only", zkServer instanceof ReadOnlyZooKeeperServer);
+            return response;
+        }
+
+    }
+
+    /**
+     * Command returns information of the last snapshot that zookeeper server
+     * has finished saving to disk. During the time between the server starts up
+     * and it finishes saving its first snapshot, the command returns the zxid
+     * and last modified time of the snapshot file used for restoration at
+     * server startup. Returned map contains:
+     * - "zxid": String
+     * - "timestamp": Long
+     */
+    public static class LastSnapshotCommand extends GetCommand {
+
+        public LastSnapshotCommand() {
+            super(Arrays.asList("last_snapshot", "lsnp"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            SnapshotInfo info = zkServer.getTxnLogFactory().getLastSnapshotInfo();
+            response.put("zxid", Long.toHexString(info == null ? -1L : info.zxid));
+            response.put("timestamp", info == null ? -1L : info.timestamp);
+            return response;
+        }
+
+    }
+
+    /**
+     * Returns the leader status of this instance and the leader host string.
+     */
+    public static class LeaderCommand extends GetCommand {
+
+        public LeaderCommand() {
+            super(Arrays.asList("leader", "lead"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            if (zkServer instanceof QuorumZooKeeperServer) {
+                response.put("is_leader", zkServer instanceof LeaderZooKeeperServer);
+                QuorumPeer peer = ((QuorumZooKeeperServer) zkServer).self;
+                response.put("leader_id", peer.getLeaderId());
+                String leaderAddress = peer.getLeaderAddress();
+                response.put("leader_ip", leaderAddress != null ? leaderAddress : "");
+            } else {
+                response.put("error", "server is not initialized");
+            }
+            return response;
+        }
+
+    }
+
+    /**
+     * Some useful info for monitoring. Returned map contains:
+     * - "version": String
+     * server version
+     * - "avg_latency": Long
+     * - "max_latency": Long
+     * - "min_latency": Long
+     * - "packets_received": Long
+     * - "packets_sents": Long
+     * - "num_alive_connections": Integer
+     * - "outstanding_requests": Long
+     * number of unprocessed requests
+     * - "server_state": "leader", "follower", or "standalone"
+     * - "znode_count": Integer
+     * - "watch_count": Integer
+     * - "ephemerals_count": Integer
+     * - "approximate_data_size": Long
+     * - "open_file_descriptor_count": Long (unix only)
+     * - "max_file_descriptor_count": Long (unix only)
+     * - "fsync_threshold_exceed_count": Long
+     * - "non_mtls_conn_count": Long
+     * - "non_mtls_remote_conn_count": Long
+     * - "non_mtls_local_conn_count": Long
+     * - "followers": Integer (leader only)
+     * - "synced_followers": Integer (leader only)
+     * - "pending_syncs": Integer (leader only)
+     */
+    public static class MonitorCommand extends GetCommand {
+
+        public MonitorCommand() {
+            super(Arrays.asList("monitor", "mntr"), false);
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            zkServer.dumpMonitorValues(response::put);
+            ServerMetrics.getMetrics().getMetricsProvider().dump(response::put);
+            return response;
+
+        }
+
+    }
+
+    /**
+     * Reset all observer connection statistics.
+     */
+    public static class ObserverCnxnStatResetCommand extends GetCommand {
+
+        public ObserverCnxnStatResetCommand() {
+            super(Arrays.asList("observer_connection_stat_reset", "orst"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            if (zkServer instanceof LeaderZooKeeperServer) {
+                Leader leader = ((LeaderZooKeeperServer) zkServer).getLeader();
+                leader.resetObserverConnectionStats();
+            } else if (zkServer instanceof FollowerZooKeeperServer) {
+                Follower follower = ((FollowerZooKeeperServer) zkServer).getFollower();
+                follower.resetObserverConnectionStats();
+            }
+            return response;
+        }
+
+    }
+
+    /**
+     * Restore from snapshot on the current server.
+     *
+     * Returned map contains:
+     * - "last_zxid": String
+     */
+    public static class RestoreCommand extends PostCommand {
+        static final String RESPONSE_DATA_LAST_ZXID = "last_zxid";
+        static final String ADMIN_RESTORE_ENABLED = "zookeeper.admin.restore.enabled";
+
+        private RateLimiter rateLimiter;
+
+        public RestoreCommand() {
+            super(Arrays.asList("restore", "rest"), true, new AuthRequest(ZooDefs.Perms.ALL, ROOT_PATH));
+            rateLimiter = new RateLimiter(1, rateLimiterInterval, TimeUnit.MILLISECONDS);
+        }
+
+        @Override
+        public CommandResponse runPost(final ZooKeeperServer zkServer, final InputStream inputStream) {
+            final CommandResponse response = initializeResponse();
+
+            // check feature flag
+            final boolean restoreEnabled = Boolean.parseBoolean(System.getProperty(ADMIN_RESTORE_ENABLED, "true"));
+            if (!restoreEnabled) {
+                response.setStatusCode(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+                LOG.warn("Restore command is disabled");
+                return response;
+            }
+
+            if (!zkServer.isSerializeLastProcessedZxidEnabled()) {
+                response.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+                LOG.warn("Restore command requires serializeLastProcessedZxidEnable flag is set to true");
+                return response;
+            }
+
+            if (inputStream == null) {
+                response.setStatusCode(HttpServletResponse.SC_BAD_REQUEST);
+                LOG.warn("InputStream from restore request is null");
+                return response;
+            }
+
+            // check rate limiting
+            if (!rateLimiter.allow()) {
+                response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS_429);
+                ServerMetrics.getMetrics().RESTORE_RATE_LIMITED_COUNT.add(1);
+                LOG.warn("Restore request was rate limited");
+                return response;
+            }
+
+            // restore from snapshot InputStream
+            try {
+                final long lastZxid = zkServer.restoreFromSnapshot(inputStream);
+                response.put(RESPONSE_DATA_LAST_ZXID, lastZxid);
+            } catch (final Exception e) {
+                response.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+                ServerMetrics.getMetrics().RESTORE_ERROR_COUNT.add(1);
+                LOG.warn("Exception occurred when restore snapshot via the restore command", e);
+            }
+            return response;
+        }
+    }
+
+    /**
+     * No-op command, check if the server is running.
+     */
+    public static class RuokCommand extends GetCommand {
+
+        public RuokCommand() {
+            super(Arrays.asList("ruok"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            return initializeResponse();
+        }
+
+    }
+
+    /**
+     * Sets the trace mask. Required arguments:
+     * - "traceMask": Long
+     * Returned Map contains:
+     * - "tracemask": Long
+     */
+    public static class SetTraceMaskCommand extends GetCommand {
+
+        public SetTraceMaskCommand() {
+            super(Arrays.asList("set_trace_mask", "stmk"), false);
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            long traceMask;
+            if (!kwargs.containsKey("traceMask")) {
+                response.put("error", "setTraceMask requires long traceMask argument");
+                return response;
+            }
+            try {
+                traceMask = Long.parseLong(kwargs.get("traceMask"));
+            } catch (NumberFormatException e) {
+                response.put("error", "setTraceMask requires long traceMask argument, got " + kwargs.get("traceMask"));
+                return response;
+            }
+
+            ZooTrace.setTextTraceLevel(traceMask);
+            response.put("tracemask", traceMask);
+            return response;
+        }
+
+    }
+
+    /**
+     * Take a snapshot of current server and stream out the data.
+     *
+     * Argument:
+     * - "streaming": optional String to indicate whether streaming out data
+     *
+     * Returned snapshot as stream if streaming is true and metadata of the snapshot
+     * - "last_zxid": String
+     * - "snapshot_size": String
+     */
+    public static class SnapshotCommand extends GetCommand {
+        static final String REQUEST_QUERY_PARAM_STREAMING = "streaming";
+
+        static final String RESPONSE_HEADER_LAST_ZXID = "last_zxid";
+        static final String RESPONSE_HEADER_SNAPSHOT_SIZE = "snapshot_size";
+
+        static final String ADMIN_SNAPSHOT_ENABLED = "zookeeper.admin.snapshot.enabled";
+
+        private final RateLimiter rateLimiter;
+
+        public SnapshotCommand() {
+            super(Arrays.asList("snapshot", "snap"), true, new AuthRequest(ZooDefs.Perms.ALL, ROOT_PATH));
+            rateLimiter = new RateLimiter(1, rateLimiterInterval, TimeUnit.MICROSECONDS);
+        }
+
+        @SuppressFBWarnings(value = "OBL_UNSATISFIED_OBLIGATION",
+                justification = "FileInputStream is passed to CommandResponse and closed in StreamOutputter")
+        @Override
+        public CommandResponse runGet(final ZooKeeperServer zkServer, final Map<String, String> kwargs) {
+            final CommandResponse response = initializeResponse();
+
+            // check feature flag
+            final boolean snapshotEnabled = Boolean.parseBoolean(System.getProperty(ADMIN_SNAPSHOT_ENABLED, "true"));
+            if (!snapshotEnabled) {
+                response.setStatusCode(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+                LOG.warn("Snapshot command is disabled");
+                return response;
+            }
+
+            if (!zkServer.isSerializeLastProcessedZxidEnabled()) {
+                response.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+                LOG.warn("Snapshot command requires serializeLastProcessedZxidEnable flag is set to true");
+                return response;
+            }
+
+            // check rate limiting
+            if (!rateLimiter.allow()) {
+                response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS_429);
+                ServerMetrics.getMetrics().SNAPSHOT_RATE_LIMITED_COUNT.add(1);
+                LOG.warn("Snapshot request was rate limited");
+                return response;
+            }
+
+            // check the streaming query param
+            boolean streaming = true;
+            if (kwargs.containsKey(REQUEST_QUERY_PARAM_STREAMING)) {
+                streaming = Boolean.parseBoolean(kwargs.get(REQUEST_QUERY_PARAM_STREAMING));
+            }
+
+            // take snapshot and stream out data if needed
+            try {
+                final File snapshotFile = zkServer.takeSnapshot(false, false);
+                final long lastZxid = Util.getZxidFromName(snapshotFile.getName(), SNAPSHOT_FILE_PREFIX);
+                response.addHeader(RESPONSE_HEADER_LAST_ZXID, "0x" + ZxidUtils.zxidToString(lastZxid));
+
+                final long size = snapshotFile.length();
+                response.addHeader(RESPONSE_HEADER_SNAPSHOT_SIZE, String.valueOf(size));
+
+                if (size == 0) {
+                    response.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+                    ServerMetrics.getMetrics().SNAPSHOT_ERROR_COUNT.add(1);
+                    LOG.warn("Snapshot file {} is empty", snapshotFile);
+                } else if (streaming) {
+                    response.setInputStream(new FileInputStream(snapshotFile));
+                }
+            } catch (final Exception e) {
+                response.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+                ServerMetrics.getMetrics().SNAPSHOT_ERROR_COUNT.add(1);
+                LOG.warn("Exception occurred when taking the snapshot via the snapshot admin command", e);
+            }
+            return response;
+        }
+    }
+
+    /**
+     * Server information. Returned map contains:
+     * - "version": String
+     * version of server
+     * - "read_only": Boolean
+     * is server in read-only mode
+     * - "server_stats": ServerStats object
+     * - "node_count": Integer
+     */
+    public static class SrvrCommand extends GetCommand {
+
+        public SrvrCommand() {
+            super(Arrays.asList("server_stats", "srvr"));
+        }
+
+        // Allow subclasses (e.g. StatCommand) to specify their own names
+        protected SrvrCommand(List<String> names) {
+            super(names);
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            LOG.info("running stat");
+            response.put("version", Version.getFullVersion());
+            response.put("read_only", zkServer instanceof ReadOnlyZooKeeperServer);
+            response.put("server_stats", zkServer.serverStats());
+            response.put("client_response", zkServer.serverStats().getClientResponseStats());
+            if (zkServer instanceof LeaderZooKeeperServer) {
+                Leader leader = ((LeaderZooKeeperServer) zkServer).getLeader();
+                response.put("proposal_stats", leader.getProposalStats());
+            }
+            response.put("node_count", zkServer.getZKDatabase().getNodeCount());
+            return response;
+        }
+
+    }
+
+    /**
+     * Same as SrvrCommand but has extra "connections" entry.
+     */
+    public static class StatCommand extends SrvrCommand {
+
+        public StatCommand() {
+            super(Arrays.asList("stats", "stat"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = super.runGet(zkServer, kwargs);
+
+            final Iterable<Map<String, Object>> connections;
+            if (zkServer.getServerCnxnFactory() != null) {
+                connections = zkServer.getServerCnxnFactory().getAllConnectionInfo(true);
+            } else {
+                connections = Collections.emptyList();
+            }
+            response.put("connections", connections);
+
+            final Iterable<Map<String, Object>> secureConnections;
+            if (zkServer.getSecureServerCnxnFactory() != null) {
+                secureConnections = zkServer.getSecureServerCnxnFactory().getAllConnectionInfo(true);
+            } else {
+                secureConnections = Collections.emptyList();
+            }
+            response.put("secure_connections", secureConnections);
+            return response;
+        }
+
+    }
+
+    /**
+     * Resets server statistics.
+     */
+    public static class StatResetCommand extends GetCommand {
+
+        public StatResetCommand() {
+            super(Arrays.asList("stat_reset", "srst"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            zkServer.serverStats().reset();
+            return response;
+        }
+
+    }
+
+    /**
+     * Information on observer connections to server. Returned Map contains:
+     * - "synced_observers": Integer (leader/follower only)
+     * - "observers": list of observer learner handler info objects (leader/follower only)
+     *
+     * @see org.apache.zookeeper.server.quorum.LearnerHandler#getLearnerHandlerInfo()
+     */
+    public static class SyncedObserverConsCommand extends GetCommand {
+
+        public SyncedObserverConsCommand() {
+            super(Arrays.asList("observers", "obsr"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+
+            CommandResponse response = initializeResponse();
+
+            if (zkServer instanceof LeaderZooKeeperServer) {
+                Leader leader = ((LeaderZooKeeperServer) zkServer).getLeader();
+
+                response.put("synced_observers", leader.getObservingLearners().size());
+                response.put("observers", leader.getObservingLearnersInfo());
+                return response;
+            } else if (zkServer instanceof FollowerZooKeeperServer) {
+                Follower follower = ((FollowerZooKeeperServer) zkServer).getFollower();
+                Integer syncedObservers = follower.getSyncedObserverSize();
+                if (syncedObservers != null) {
+                    response.put("synced_observers", syncedObservers);
+                    response.put("observers", follower.getSyncedObserversInfo());
+                    return response;
+                }
+            }
+
+            response.put("synced_observers", 0);
+            response.put("observers", Collections.emptySet());
+            return response;
+        }
+
+    }
+
+    /**
+     * All defined system properties.
+     */
+    public static class SystemPropertiesCommand extends GetCommand {
+
+        public SystemPropertiesCommand() {
+            super(Arrays.asList("system_properties", "sysp"), false);
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            Properties systemProperties = System.getProperties();
+            SortedMap<String, String> sortedSystemProperties = new TreeMap<>();
+            systemProperties.forEach((k, v) -> sortedSystemProperties.put(k.toString(), v.toString()));
+            response.putAll(sortedSystemProperties);
+            return response;
+        }
+
+    }
+
+    /**
+     * Returns the current ensemble configuration information.
+     * It provides list of current voting members in the ensemble.
+     */
+    public static class VotingViewCommand extends GetCommand {
+
+        public VotingViewCommand() {
+            super(Arrays.asList("voting_view"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            if (zkServer instanceof QuorumZooKeeperServer) {
+                QuorumPeer peer = ((QuorumZooKeeperServer) zkServer).self;
+                Map<Long, QuorumServerView> votingView = peer.getVotingView().entrySet().stream()
+                        .collect(Collectors.toMap(Map.Entry::getKey, e -> new QuorumServerView(e.getValue())));
+                response.put("current_config", votingView);
+            } else {
+                response.put("current_config", Collections.emptyMap());
+            }
+            return response;
+        }
+
+        @SuppressFBWarnings(value = "URF_UNREAD_FIELD", justification = "class is used only for JSON serialization")
+        private static class QuorumServerView {
+
+            @JsonProperty
+            private List<String> serverAddresses;
+
+            @JsonProperty
+            private List<String> electionAddresses;
+
+            @JsonProperty
+            private String clientAddress;
+
+            @JsonProperty
+            private String learnerType;
+
+            public QuorumServerView(QuorumPeer.QuorumServer quorumServer) {
+                this.serverAddresses = getMultiAddressString(quorumServer.addr);
+                this.electionAddresses = getMultiAddressString(quorumServer.electionAddr);
+                this.learnerType = quorumServer.type.equals(LearnerType.PARTICIPANT) ? "participant" : "observer";
+                this.clientAddress = getAddressString(quorumServer.clientAddr);
+            }
+
+            private static List<String> getMultiAddressString(MultipleAddresses multipleAddresses) {
+                if (multipleAddresses == null) {
+                    return Collections.emptyList();
+                }
+
+                return multipleAddresses.getAllAddresses().stream()
+                        .map(QuorumServerView::getAddressString)
+                        .collect(Collectors.toList());
+            }
+
+            private static String getAddressString(InetSocketAddress address) {
+                if (address == null) {
+                    return "";
+                }
+                return String.format("%s:%d", QuorumPeer.QuorumServer.delimitedHostString(address), address.getPort());
+            }
+        }
+
+    }
+
+    /**
+     * Watch information aggregated by session. Returned Map contains:
+     * - "session_id_to_watched_paths": Map&lt;Long, Set&lt;String&gt;&gt; session ID -&gt; watched paths
+     *
+     * @see DataTree#getWatches()
+     * @see DataTree#getWatches()
+     */
+    public static class WatchCommand extends GetCommand {
+
+        public WatchCommand() {
+            super(Arrays.asList("watches", "wchc"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            DataTree dt = zkServer.getZKDatabase().getDataTree();
+            CommandResponse response = initializeResponse();
+            response.put("session_id_to_watched_paths", dt.getWatches().toMap());
+            return response;
+        }
+
+    }
+
+    /**
+     * Watch information aggregated by path. Returned Map contains:
+     * - "path_to_session_ids": Map&lt;String, Set&lt;Long&gt;&gt; path -&gt; session IDs of sessions watching path
+     *
+     * @see DataTree#getWatchesByPath()
+     */
+    public static class WatchesByPathCommand extends GetCommand {
+
+        public WatchesByPathCommand() {
+            super(Arrays.asList("watches_by_path", "wchp"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            DataTree dt = zkServer.getZKDatabase().getDataTree();
+            CommandResponse response = initializeResponse();
+            response.put("path_to_session_ids", dt.getWatchesByPath().toMap());
+            return response;
+        }
+
+    }
+
+    /**
+     * Summarized watch information.
+     *
+     * @see DataTree#getWatchesSummary()
+     */
+    public static class WatchSummaryCommand extends GetCommand {
+
+        public WatchSummaryCommand() {
+            super(Arrays.asList("watch_summary", "wchs"));
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            DataTree dt = zkServer.getZKDatabase().getDataTree();
+            CommandResponse response = initializeResponse();
+            response.putAll(dt.getWatchesSummary().toMap());
+            return response;
+        }
+
+    }
+
+    /**
+     * Returns the current phase of Zab protocol that peer is running.
+     * It can be in one of these phases: ELECTION, DISCOVERY, SYNCHRONIZATION, BROADCAST
+     */
+    public static class ZabStateCommand extends GetCommand {
+
+        public ZabStateCommand() {
+            super(Arrays.asList("zabstate"), false);
+        }
+
+        @Override
+        public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
+            CommandResponse response = initializeResponse();
+            if (zkServer instanceof QuorumZooKeeperServer) {
+                QuorumPeer peer = ((QuorumZooKeeperServer) zkServer).self;
+                QuorumPeer.ZabState zabState = peer.getZabState();
+                QuorumVerifier qv = peer.getQuorumVerifier();
+
+                QuorumPeer.QuorumServer voter = qv.getVotingMembers().get(peer.getMyId());
+                boolean voting = (
+                        voter != null
+                                && voter.addr.equals(peer.getQuorumAddress())
+                                && voter.electionAddr.equals(peer.getElectionAddress())
+                );
+                response.put("myid", zkServer.getConf().getServerId());
+                response.put("is_leader", zkServer instanceof LeaderZooKeeperServer);
+                response.put("quorum_address", peer.getQuorumAddress());
+                response.put("election_address", peer.getElectionAddress());
+                response.put("client_address", peer.getClientAddress());
+                response.put("voting", voting);
+                long lastProcessedZxid = zkServer.getZKDatabase().getDataTreeLastProcessedZxid();
+                response.put("last_zxid", "0x" + ZxidUtils.zxidToString(lastProcessedZxid));
+                response.put("zab_epoch", ZxidUtils.getEpochFromZxid(lastProcessedZxid));
+                response.put("zab_counter", ZxidUtils.getCounterFromZxid(lastProcessedZxid));
+                response.put("zabstate", zabState.name().toLowerCase());
+            } else {
+                response.put("voting", false);
+                response.put("zabstate", "");
+            }
+            return response;
+        }
+
+    }
+
+    private Commands() {
+    }
+
+}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/DummyAdminServer.java
similarity index 60%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/DummyAdminServer.java
index 65cc10e0d3b..4750ed2215f 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/DummyAdminServer.java
@@ -16,30 +16,29 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
+package org.apache.zookeeper.server.admin;
 
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
+import org.apache.zookeeper.server.ZooKeeperServer;
 
-public class MockAdditionalServlet implements AdditionalServlet {
+/**
+ * An AdminServer that does nothing.
+ *
+ * We use this class when we wish to disable the AdminServer. (This way we only
+ * have to consider whether the server is enabled when we create the
+ * AdminServer, which is handled by AdminServerFactory.)
+ */
+public class DummyAdminServer implements AdminServer {
 
     @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
-
+    public void start() throws AdminServerException {
     }
 
     @Override
-    public String getBasePath() {
-        return null;
+    public void shutdown() throws AdminServerException {
     }
 
     @Override
-    public ServletHolder getServletHolder() {
-        return null;
+    public void setZooKeeperServer(ZooKeeperServer zkServer) {
     }
 
-    @Override
-    public void close() {
-        // no-op
-    }
 }
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/GetCommand.java
similarity index 56%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/GetCommand.java
index 65cc10e0d3b..90673a93066 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/GetCommand.java
@@ -16,30 +16,32 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
+package org.apache.zookeeper.server.admin;
 
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
+import java.io.InputStream;
+import java.util.List;
+import org.apache.zookeeper.server.ZooKeeperServer;
 
-public class MockAdditionalServlet implements AdditionalServlet {
+/**
+ * Command that represents HTTP GET request.
+ */
 
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
+public abstract class GetCommand extends CommandBase {
 
+    protected GetCommand(List<String> names) {
+        super(names);
     }
 
-    @Override
-    public String getBasePath() {
-        return null;
+    protected GetCommand(List<String> names, boolean serverRequired) {
+        super(names, serverRequired);
     }
 
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
+    protected GetCommand(List<String> names, boolean serverRequired, AuthRequest authRequest) {
+        super(names, serverRequired, authRequest);
     }
 
     @Override
-    public void close() {
-        // no-op
+    public CommandResponse runPost(ZooKeeperServer zkServer, InputStream inputStream) {
+        return null;
     }
 }
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/JettyAdminServer.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/JettyAdminServer.java
new file mode 100644
index 00000000000..1a487c00fd0
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/JettyAdminServer.java
@@ -0,0 +1,397 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.zookeeper.common.QuorumX509Util;
+import org.apache.zookeeper.common.SecretUtils;
+import org.apache.zookeeper.common.X509Util;
+import org.apache.zookeeper.server.ZooKeeperServer;
+import org.apache.zookeeper.server.auth.IPAuthenticationProvider;
+import org.eclipse.jetty.ee8.nested.ServletConstraint;
+import org.eclipse.jetty.ee8.security.ConstraintMapping;
+import org.eclipse.jetty.ee8.security.ConstraintSecurityHandler;
+import org.eclipse.jetty.ee8.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee8.servlet.ServletHolder;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpVersion;
+import org.eclipse.jetty.server.DetectorConnectionFactory;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.SecureRequestCustomizer;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.SslConnectionFactory;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class encapsulates a Jetty server for running Commands.
+ *
+ * Given the default settings, start a ZooKeeper server and visit
+ * http://hostname:8080/commands for links to all registered commands. Visiting
+ * http://hostname:8080/commands/commandname will execute the associated
+ * Command and return the result in the body of the response. Any keyword
+ * arguments to the command are specified with URL parameters (e.g.,
+ * http://localhost:8080/commands/set_trace_mask?traceMask=306).
+ *
+ * @see Commands
+ * @see CommandOutputter
+ */
+public class JettyAdminServer implements AdminServer {
+
+    static final Logger LOG = LoggerFactory.getLogger(JettyAdminServer.class);
+
+    public static final int DEFAULT_PORT = 8080;
+    public static final int DEFAULT_IDLE_TIMEOUT = 30000;
+    public static final String DEFAULT_COMMAND_URL = "/commands";
+    private static final String DEFAULT_ADDRESS = "0.0.0.0";
+    public static final int DEFAULT_STS_MAX_AGE = 1 * 24 * 60 * 60;  // seconds in a day
+    public static final int DEFAULT_HTTP_VERSION = 11;  // based on HttpVersion.java in jetty
+
+    private final Server server;
+    private final String address;
+    private final int port;
+    private final int idleTimeout;
+    private final String commandUrl;
+    private ZooKeeperServer zkServer;
+
+    public JettyAdminServer() throws AdminServerException, IOException, GeneralSecurityException {
+        this(
+                System.getProperty("zookeeper.admin.serverAddress", DEFAULT_ADDRESS),
+                Integer.getInteger("zookeeper.admin.serverPort", DEFAULT_PORT),
+                Integer.getInteger("zookeeper.admin.idleTimeout", DEFAULT_IDLE_TIMEOUT),
+                System.getProperty("zookeeper.admin.commandURL", DEFAULT_COMMAND_URL),
+                Integer.getInteger("zookeeper.admin.httpVersion", DEFAULT_HTTP_VERSION),
+                Boolean.getBoolean("zookeeper.admin.portUnification"),
+                Boolean.getBoolean("zookeeper.admin.forceHttps"),
+                Boolean.getBoolean("zookeeper.admin.needClientAuth"));
+    }
+
+    public JettyAdminServer(
+            String address,
+            int port,
+            int timeout,
+            String commandUrl,
+            int httpVersion,
+            boolean portUnification,
+            boolean forceHttps,
+            boolean needClientAuth) throws IOException, GeneralSecurityException {
+
+        this.port = port;
+        this.idleTimeout = timeout;
+        this.commandUrl = commandUrl;
+        this.address = address;
+
+        server = new Server();
+        ServerConnector connector = null;
+
+        if (!portUnification && !forceHttps) {
+            connector = new ServerConnector(server);
+        } else {
+            SecureRequestCustomizer customizer = new SecureRequestCustomizer();
+            customizer.setStsMaxAge(DEFAULT_STS_MAX_AGE);
+            customizer.setStsIncludeSubDomains(true);
+
+            HttpConfiguration config = new HttpConfiguration();
+            config.setSecureScheme("https");
+            config.addCustomizer(customizer);
+
+            try (QuorumX509Util x509Util = new QuorumX509Util()) {
+                String privateKeyType = System.getProperty(x509Util.getSslKeystoreTypeProperty(), "");
+                String privateKeyPath = System.getProperty(x509Util.getSslKeystoreLocationProperty(), "");
+                String privateKeyPassword = getPasswordFromSystemPropertyOrFile(
+                        x509Util.getSslKeystorePasswdProperty(),
+                        x509Util.getSslKeystorePasswdPathProperty());
+
+                String certAuthType = System.getProperty(x509Util.getSslTruststoreTypeProperty(), "");
+                String certAuthPath = System.getProperty(x509Util.getSslTruststoreLocationProperty(), "");
+                String certAuthPassword = getPasswordFromSystemPropertyOrFile(
+                        x509Util.getSslTruststorePasswdProperty(),
+                        x509Util.getSslTruststorePasswdPathProperty());
+                KeyStore keyStore = null, trustStore = null;
+
+                try {
+                    keyStore = X509Util.loadKeyStore(privateKeyPath, privateKeyPassword, privateKeyType);
+                    trustStore = X509Util.loadTrustStore(certAuthPath, certAuthPassword, certAuthType);
+                    LOG.info("Successfully loaded private key from {}", privateKeyPath);
+                    LOG.info("Successfully loaded certificate authority from {}", certAuthPath);
+                } catch (Exception e) {
+                    LOG.error("Failed to load authentication certificates for admin server.", e);
+                    throw e;
+                }
+
+                SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
+                sslContextFactory.setKeyStore(keyStore);
+                sslContextFactory.setKeyStorePassword(privateKeyPassword);
+                sslContextFactory.setTrustStore(trustStore);
+                sslContextFactory.setTrustStorePassword(certAuthPassword);
+                sslContextFactory.setNeedClientAuth(needClientAuth);
+
+                SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory,
+                        HttpVersion.fromVersion(httpVersion).asString());
+                if (forceHttps) {
+                    connector = new ServerConnector(server,
+                            sslConnectionFactory,
+                            new HttpConnectionFactory(config));
+                } else {
+                    connector = new ServerConnector(
+                            server,
+                            new DetectorConnectionFactory(sslConnectionFactory),
+                            new HttpConnectionFactory(config));
+                    connector.setDefaultProtocol(sslConnectionFactory.getProtocol());
+                }
+            }
+        }
+
+        connector.setHost(address);
+        connector.setPort(port);
+        connector.setIdleTimeout(idleTimeout);
+
+        server.addConnector(connector);
+
+        ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
+        context.setContextPath("/*");
+        constrainTraceMethod(context);
+        server.setHandler(context);
+
+        context.addServlet(new ServletHolder(new CommandServlet()), commandUrl + "/*");
+    }
+
+    /**
+     * Start the embedded Jetty server.
+     */
+    @Override
+    public void start() throws AdminServerException {
+        try {
+            server.start();
+        } catch (Exception e) {
+            // Server.start() only throws Exception, so let's at least wrap it
+            // in an identifiable subclass
+            String message = String.format(
+                    "Problem starting AdminServer on address %s, port %d and command URL %s",
+                    address,
+                    port,
+                    commandUrl);
+            throw new AdminServerException(message, e);
+        }
+        LOG.info("Started AdminServer on address {}, port {} and command URL {}", address, port, commandUrl);
+    }
+
+    /**
+     * Stop the embedded Jetty server.
+     *
+     * This is not very important except for tests where multiple
+     * JettyAdminServers are started and may try to bind to the same ports if
+     * previous servers aren't shut down.
+     */
+    @Override
+    public void shutdown() throws AdminServerException {
+        try {
+            server.stop();
+        } catch (Exception e) {
+            String message = String.format(
+                    "Problem stopping AdminServer on address %s, port %d and command URL %s",
+                    address,
+                    port,
+                    commandUrl);
+            throw new AdminServerException(message, e);
+        }
+    }
+
+    /**
+     * Set the ZooKeeperServer that will be used to run Commands.
+     *
+     * It is not necessary to set the ZK server before calling
+     * AdminServer.start(), and the ZK server can be set to null when, e.g.,
+     * that server is being shut down. If the ZK server is not set or set to
+     * null, the AdminServer will still be able to issue Commands, but they will
+     * return an error until a ZK server is set.
+     */
+    @Override
+    public void setZooKeeperServer(ZooKeeperServer zkServer) {
+        this.zkServer = zkServer;
+    }
+
+    private class CommandServlet extends HttpServlet {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected void doGet(
+                HttpServletRequest request,
+                HttpServletResponse response) throws ServletException, IOException {
+            // Capture the command name from the URL
+            String cmd = request.getPathInfo();
+            if (cmd == null || cmd.equals("/")) {
+                // No command specified, print links to all commands instead
+                for (String link : commandLinks()) {
+                    response.getWriter().println(link);
+                    response.getWriter().println("<br/>");
+                }
+                return;
+            }
+            // Strip leading "/"
+            cmd = cmd.substring(1);
+
+            // Extract keyword arguments to command from request parameters
+            @SuppressWarnings("unchecked") Map<String, String[]> parameterMap = request.getParameterMap();
+            Map<String, String> kwargs = new HashMap<>();
+            for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
+                kwargs.put(entry.getKey(), entry.getValue()[0]);
+            }
+            final String authInfo = request.getHeader(HttpHeader.AUTHORIZATION.asString());
+
+            // Run the command
+            final CommandResponse cmdResponse = Commands.runGetCommand(cmd, zkServer, kwargs, authInfo, request);
+            response.setStatus(cmdResponse.getStatusCode());
+
+            final Map<String, String> headers = cmdResponse.getHeaders();
+            for (final Map.Entry<String, String> header : headers.entrySet()) {
+                response.addHeader(header.getKey(), header.getValue());
+            }
+            final String clientIP = IPAuthenticationProvider.getClientIPAddress(request);
+            if (cmdResponse.getInputStream() == null) {
+                // Format and print the output of the command
+                CommandOutputter outputter = new JsonOutputter(clientIP);
+                response.setContentType(outputter.getContentType());
+                outputter.output(cmdResponse, response.getWriter());
+            } else {
+                // Stream out the output of the command
+                CommandOutputter outputter = new StreamOutputter(clientIP);
+                response.setContentType(outputter.getContentType());
+                outputter.output(cmdResponse, response.getOutputStream());
+            }
+        }
+
+        /**
+         * Serves HTTP POST requests. It reads request payload as raw data.
+         * It's up to each command to process the payload accordingly.
+         * For example, RestoreCommand uses the payload InputStream directly
+         * to read snapshot data.
+         */
+        @Override
+        protected void doPost(final HttpServletRequest request,
+                              final HttpServletResponse response) throws ServletException, IOException {
+            final String cmdName = extractCommandNameFromURL(request, response);
+            if (cmdName != null) {
+                final String authInfo = request.getHeader(HttpHeader.AUTHORIZATION.asString());
+                final CommandResponse cmdResponse =
+                        Commands.runPostCommand(cmdName, zkServer, request.getInputStream(), authInfo, request);
+                final String clientIP = IPAuthenticationProvider.getClientIPAddress(request);
+                sendJSONResponse(response, cmdResponse, clientIP);
+            }
+        }
+
+        /**
+         * Extracts the command name from URL if it exists otherwise null.
+         */
+        private String extractCommandNameFromURL(final HttpServletRequest request,
+                                                 final HttpServletResponse response) throws IOException {
+            String cmd = request.getPathInfo();
+            if (cmd == null || cmd.equals("/")) {
+                printCommandLinks(response);
+                return null;
+            }
+            // Strip leading "/"
+            return cmd.substring(1);
+        }
+
+        /**
+         * Prints the list of URLs to each registered command as response.
+         */
+        private void printCommandLinks(final HttpServletResponse response) throws IOException {
+            for (final String link : commandLinks()) {
+                response.getWriter().println(link);
+                response.getWriter().println("<br/>");
+            }
+        }
+
+        /**
+         * Send JSON string as the response.
+         */
+        private void sendJSONResponse(final HttpServletResponse response,
+                                      final CommandResponse cmdResponse,
+                                      final String clientIP) throws IOException {
+            final CommandOutputter outputter = new JsonOutputter(clientIP);
+
+            response.setStatus(cmdResponse.getStatusCode());
+            response.setContentType(outputter.getContentType());
+            outputter.output(cmdResponse, response.getWriter());
+        }
+    }
+
+    /**
+     * Returns a list of URLs to each registered Command.
+     */
+    private List<String> commandLinks() {
+        return Commands.getPrimaryNames().stream().sorted()
+                .map(command -> String.format("<a href=\"%s\">%s</a>", commandUrl + "/" + command, command))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Add constraint to a given context to disallow TRACE method.
+     *
+     * @param ctxHandler the context to modify
+     */
+    private void constrainTraceMethod(ServletContextHandler ctxHandler) {
+        ServletConstraint c = new ServletConstraint();
+        c.setAuthenticate(true);
+
+        ConstraintMapping cmt = new ConstraintMapping();
+        cmt.setConstraint(c);
+        cmt.setMethod("TRACE");
+        cmt.setPathSpec("/*");
+
+        ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
+        securityHandler.setConstraintMappings(new ConstraintMapping[]{cmt});
+
+        ctxHandler.setSecurityHandler(securityHandler);
+    }
+
+    /**
+     * Returns the password specified by the given property or stored in the file specified by the
+     * given path property. If both are specified, the password stored in the file will be returned.
+     *
+     * @param propertyName     the name of the property
+     * @param pathPropertyName the name of the path property
+     * @return password value
+     */
+    private String getPasswordFromSystemPropertyOrFile(final String propertyName,
+                                                       final String pathPropertyName) {
+        String value = System.getProperty(propertyName, "");
+        final String pathValue = System.getProperty(pathPropertyName, "");
+        if (!pathValue.isEmpty()) {
+            value = String.valueOf(SecretUtils.readSecret(pathValue));
+        }
+        return value;
+    }
+}
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/JsonOutputter.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/JsonOutputter.java
new file mode 100644
index 00000000000..631f43508c7
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/JsonOutputter.java
@@ -0,0 +1,69 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import com.fasterxml.jackson.core.JsonGenerationException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import java.io.IOException;
+import java.io.PrintWriter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class JsonOutputter implements CommandOutputter {
+
+    static final Logger LOG = LoggerFactory.getLogger(JsonOutputter.class);
+
+    public static final String ERROR_RESPONSE = "{\"error\": \"Exception writing command response to JSON\"}";
+
+    private ObjectMapper mapper;
+    private final String clientIP;
+
+    public JsonOutputter(final String clientIP) {
+        mapper = new ObjectMapper();
+        mapper.configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true);
+        mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
+        mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
+        this.clientIP = clientIP;
+    }
+
+    @Override
+    public String getContentType() {
+        return "application/json";
+    }
+
+    @Override
+    public void output(CommandResponse response, PrintWriter pw) {
+        try {
+            mapper.writeValue(pw, response.toMap());
+        } catch (JsonGenerationException e) {
+            LOG.warn("Exception writing command response to JSON:", e);
+            pw.write(ERROR_RESPONSE);
+        } catch (JsonMappingException e) {
+            LOG.warn("Exception writing command response to JSON:", e);
+            pw.write(ERROR_RESPONSE);
+        } catch (IOException e) {
+            LOG.warn("Exception writing command response as JSON to {}", clientIP, e);
+            pw.write(ERROR_RESPONSE);
+        }
+    }
+
+}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/PostCommand.java
similarity index 62%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/PostCommand.java
index 65cc10e0d3b..23795ed34ae 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/PostCommand.java
@@ -16,30 +16,22 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
-
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
-
-public class MockAdditionalServlet implements AdditionalServlet {
+/**
+ * Command that represents HTTP POST request
+ */
+package org.apache.zookeeper.server.admin;
 
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
+import java.util.List;
+import java.util.Map;
+import org.apache.zookeeper.server.ZooKeeperServer;
 
+public abstract class PostCommand extends CommandBase {
+    protected PostCommand(List<String> names, boolean serverRequired, AuthRequest authRequest) {
+        super(names, serverRequired, authRequest);
     }
 
     @Override
-    public String getBasePath() {
+    public CommandResponse runGet(ZooKeeperServer zkServer, Map<String, String> kwargs) {
         return null;
     }
-
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
-    }
-
-    @Override
-    public void close() {
-        // no-op
-    }
 }
diff --git a/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/StreamOutputter.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/StreamOutputter.java
new file mode 100644
index 00000000000..f4d9ec5c13d
--- /dev/null
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/StreamOutputter.java
@@ -0,0 +1,52 @@
+/*
+ * 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.zookeeper.server.admin;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import org.apache.zookeeper.common.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A class for streaming data out.
+ */
+public class StreamOutputter implements CommandOutputter {
+    private static final Logger LOG = LoggerFactory.getLogger(StreamOutputter.class);
+    private final String clientIP;
+
+    public StreamOutputter(final String clientIP) {
+        this.clientIP = clientIP;
+    }
+
+    @Override
+    public String getContentType() {
+        return "application/octet-stream";
+    }
+
+    @Override
+    public void output(final CommandResponse response, final OutputStream os) {
+        try (final InputStream is = response.getInputStream()) {
+            IOUtils.copyBytes(is, os, 1024, true);
+        } catch (final IOException e) {
+            LOG.warn("Exception streaming out data to {}", clientIP, e);
+        }
+    }
+}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/package-info.java
similarity index 59%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/package-info.java
index 65cc10e0d3b..85e5a06e706 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/jetty-upgrade/zookeeper-with-patched-admin/src/main/java/org/apache/zookeeper/server/admin/package-info.java
@@ -16,30 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
-
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
-
-public class MockAdditionalServlet implements AdditionalServlet {
-
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
-
-    }
-
-    @Override
-    public String getBasePath() {
-        return null;
-    }
-
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
-    }
-
-    @Override
-    public void close() {
-        // no-op
-    }
-}
+/**
+ * Admin server for ZooKeeper that is using Jetty 12.
+ */
+package org.apache.zookeeper.server.admin;
diff --git a/managed-ledger/pom.xml b/managed-ledger/pom.xml
index eac0882acda..25dd4b6853e 100644
--- a/managed-ledger/pom.xml
+++ b/managed-ledger/pom.xml
@@ -36,23 +36,12 @@
       <groupId>org.apache.bookkeeper</groupId>
       <artifactId>bookkeeper-server</artifactId>
     </dependency>
-
     <dependency>
-      <groupId>org.apache.bookkeeper.stats</groupId>
-      <artifactId>prometheus-metrics-provider</artifactId>
+      <groupId>org.apache.pulsar</groupId>
+      <artifactId>zookeeper-with-patched-admin</artifactId>
+      <version>${project.version}</version>
     </dependency>
 
-    <dependency>
-      <groupId>org.apache.bookkeeper.stats</groupId>
-      <artifactId>codahale-metrics-provider</artifactId>
-      <version>${bookkeeper.version}</version>
-      <exclusions>
-        <exclusion>
-          <artifactId>amqp-client</artifactId>
-          <groupId>com.rabbitmq</groupId>
-        </exclusion>
-      </exclusions>
-    </dependency>
 
     <dependency>
       <groupId>com.google.protobuf</groupId>
diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java
index c5f75150495..b3201a6def5 100644
--- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java
+++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java
@@ -72,6 +72,7 @@ import java.util.UUID;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
@@ -153,7 +154,6 @@ import org.apache.pulsar.metadata.api.extended.SessionEvent;
 import org.apache.pulsar.metadata.impl.FaultInjectionMetadataStore;
 import org.awaitility.Awaitility;
 import org.awaitility.reflect.WhiteboxImpl;
-import org.eclipse.jetty.util.BlockingArrayQueue;
 import org.mockito.Mockito;
 import org.testng.Assert;
 import org.testng.annotations.DataProvider;
@@ -3274,8 +3274,8 @@ public class ManagedLedgerTest extends MockedBookKeeperTestCase {
 
     private static class CollectCtxAddEntryCallback implements AddEntryCallback {
 
-        public List<Object> addCompleteCtxList = new BlockingArrayQueue<>();
-        public List<Object> addFailedCtxList = new BlockingArrayQueue<>();
+        public List<Object> addCompleteCtxList = new CopyOnWriteArrayList<>();
+        public List<Object> addFailedCtxList = new CopyOnWriteArrayList<>();
 
         @Override
         public void addComplete(Position position, ByteBuf entryData, Object ctx) {
diff --git a/pom.xml b/pom.xml
index 86ad3265bb1..635da2219b2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -186,7 +186,9 @@ flexible messaging model and an intuitive client API.</description>
     <curator.version>5.7.1</curator.version>
     <netty.version>4.1.132.Final</netty.version>
     <netty-iouring.version>0.0.26.Final</netty-iouring.version>
-    <jetty.version>9.4.58.v20250814</jetty.version>
+    <jetty.version>12.1.8</jetty.version>
+    <!-- Jetty 9 is used in tiered-storage/file-system tests and pulsar-io/alluxio -->
+    <jetty9.version>9.4.58.v20250814</jetty9.version>
     <conscrypt.version>2.5.2</conscrypt.version>
     <jersey.version>2.42</jersey.version>
     <athenz.version>1.10.62</athenz.version>
@@ -199,6 +201,7 @@ flexible messaging model and an intuitive client API.</description>
     <!-- bouncycastle dependencies aren't necessarily aligned -->
     <bouncycastle.bcprov-jdk18on.version>1.78.1</bouncycastle.bcprov-jdk18on.version>
     <bouncycastle.bcpkix-jdk18on.version>1.81</bouncycastle.bcpkix-jdk18on.version>
+    <bouncycastle.bcutil-jdk18on.version>1.81</bouncycastle.bcutil-jdk18on.version>
     <bouncycastle.bcprov-ext-jdk18on.version>1.78.1</bouncycastle.bcprov-ext-jdk18on.version>
     <bouncycastle.bcpkix-fips.version>2.0.10</bouncycastle.bcpkix-fips.version>
     <bouncycastle.bc-fips.version>2.0.1</bouncycastle.bc-fips.version>
@@ -279,7 +282,7 @@ flexible messaging model and an intuitive client API.</description>
     <jakarta.activation.version>1.2.2</jakarta.activation.version>
     <jakarta.xml.bind.version>2.3.3</jakarta.xml.bind.version>
     <jakarta.validation.version>2.0.2</jakarta.validation.version>
-    <jna.version>5.12.1</jna.version>
+    <jna.version>5.18.1</jna.version>
     <kubernetesclient.version>18.0.0</kubernetesclient.version>
     <jose4j.version>0.9.6</jose4j.version>
     <okhttp3.version>5.3.1</okhttp3.version>
@@ -553,6 +556,11 @@ flexible messaging model and an intuitive client API.</description>
             <groupId>io.netty</groupId>
             <artifactId>netty-*</artifactId>
           </exclusion>
+          <!-- exclude zookeeper dependency, use patched version -->
+          <exclusion>
+            <groupId>org.apache.zookeeper</groupId>
+            <artifactId>zookeeper</artifactId>
+          </exclusion>
         </exclusions>
       </dependency>
 
@@ -719,12 +727,6 @@ flexible messaging model and an intuitive client API.</description>
         </exclusions>
       </dependency>
 
-      <dependency>
-        <groupId>org.apache.bookkeeper.stats</groupId>
-        <artifactId>prometheus-metrics-provider</artifactId>
-        <version>${bookkeeper.version}</version>
-      </dependency>
-
       <dependency>
         <groupId>com.google.re2j</groupId>
         <artifactId>re2j</artifactId>
@@ -749,18 +751,6 @@ flexible messaging model and an intuitive client API.</description>
         <version>${jose4j.version}</version>
       </dependency>
 
-      <dependency>
-        <groupId>org.eclipse.jetty</groupId>
-        <artifactId>jetty-server</artifactId>
-        <version>${jetty.version}</version>
-      </dependency>
-
-      <dependency>
-        <groupId>org.eclipse.jetty</groupId>
-        <artifactId>jetty-alpn-conscrypt-server</artifactId>
-        <version>${jetty.version}</version>
-      </dependency>
-
       <dependency>
         <groupId>org.conscrypt</groupId>
         <artifactId>conscrypt-openjdk-uber</artifactId>
@@ -768,17 +758,17 @@ flexible messaging model and an intuitive client API.</description>
       </dependency>
 
       <dependency>
-        <groupId>org.eclipse.jetty</groupId>
-        <artifactId>jetty-bom</artifactId>
-        <version>${jetty.version}</version>
+        <groupId>io.netty</groupId>
+        <artifactId>netty-bom</artifactId>
+        <version>${netty.version}</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
 
       <dependency>
-        <groupId>io.netty</groupId>
-        <artifactId>netty-bom</artifactId>
-        <version>${netty.version}</version>
+        <groupId>com.fasterxml.jackson</groupId>
+        <artifactId>jackson-bom</artifactId>
+        <version>${jackson.version}</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
@@ -888,36 +878,6 @@ flexible messaging model and an intuitive client API.</description>
         <version>${commons-codec.version}</version>
       </dependency>
 
-      <dependency>
-        <groupId>org.glassfish.jersey.core</groupId>
-        <artifactId>jersey-server</artifactId>
-        <version>${jersey.version}</version>
-      </dependency>
-
-      <dependency>
-        <groupId>org.glassfish.jersey.core</groupId>
-        <artifactId>jersey-client</artifactId>
-        <version>${jersey.version}</version>
-      </dependency>
-
-      <dependency>
-        <groupId>org.glassfish.jersey.inject</groupId>
-        <artifactId>jersey-hk2</artifactId>
-        <version>${jersey.version}</version>
-      </dependency>
-
-      <dependency>
-        <groupId>org.glassfish.jersey.containers</groupId>
-        <artifactId>jersey-container-servlet-core</artifactId>
-        <version>${jersey.version}</version>
-      </dependency>
-
-      <dependency>
-        <groupId>org.glassfish.jersey.containers</groupId>
-        <artifactId>jersey-container-servlet</artifactId>
-        <version>${jersey.version}</version>
-      </dependency>
-
       <dependency>
         <groupId>jakarta.ws.rs</groupId>
         <artifactId>jakarta.ws.rs-api</artifactId>
@@ -925,30 +885,23 @@ flexible messaging model and an intuitive client API.</description>
       </dependency>
 
       <dependency>
-        <groupId>org.glassfish.jersey.media</groupId>
-        <artifactId>jersey-media-json-jackson</artifactId>
-        <version>${jersey.version}</version>
-        <exclusions>
-          <exclusion>
-            <groupId>jakarta.activation</groupId>
-            <artifactId>jakarta.activation-api</artifactId>
-          </exclusion>
-          <exclusion>
-            <groupId>javax.xml.bind</groupId>
-            <artifactId>jaxb-api</artifactId>
-          </exclusion>
-        </exclusions>
+        <groupId>net.java.dev.jna</groupId>
+        <artifactId>jna</artifactId>
+        <version>${jna.version}</version>
       </dependency>
-
       <dependency>
-        <groupId>org.glassfish.jersey.media</groupId>
-        <artifactId>jersey-media-multipart</artifactId>
-        <version>${jersey.version}</version>
+        <groupId>net.java.dev.jna</groupId>
+        <artifactId>jna-platform</artifactId>
+        <version>${jna.version}</version>
       </dependency>
-
       <dependency>
         <groupId>net.java.dev.jna</groupId>
-        <artifactId>jna</artifactId>
+        <artifactId>jna-jpms</artifactId>
+        <version>${jna.version}</version>
+      </dependency>
+      <dependency>
+        <groupId>net.java.dev.jna</groupId>
+        <artifactId>jna-platform-jpms</artifactId>
         <version>${jna.version}</version>
       </dependency>
 
@@ -974,14 +927,6 @@ flexible messaging model and an intuitive client API.</description>
         <version>${docker-java.version}</version>
       </dependency>
 
-      <dependency>
-        <groupId>com.fasterxml.jackson</groupId>
-        <artifactId>jackson-bom</artifactId>
-        <version>${jackson.version}</version>
-        <type>pom</type>
-        <scope>import</scope>
-      </dependency>
-
       <dependency>
         <groupId>it.unimi.dsi</groupId>
         <artifactId>fastutil</artifactId>
@@ -1043,6 +988,12 @@ flexible messaging model and an intuitive client API.</description>
         <version>${bouncycastle.bcpkix-jdk18on.version}</version>
       </dependency>
 
+      <dependency>
+        <groupId>org.bouncycastle</groupId>
+        <artifactId>bcutil-jdk18on</artifactId>
+        <version>${bouncycastle.bcutil-jdk18on.version}</version>
+      </dependency>
+
       <dependency>
         <groupId>org.bouncycastle</groupId>
         <artifactId>bcprov-ext-jdk18on</artifactId>
@@ -1381,6 +1332,10 @@ flexible messaging model and an intuitive client API.</description>
             <groupId>commons-lang</groupId>
             <artifactId>commons-lang</artifactId>
           </exclusion>
+          <exclusion>
+            <groupId>org.apache.zookeeper</groupId>
+            <artifactId>zookeeper</artifactId>
+          </exclusion>
         </exclusions>
       </dependency>
 
@@ -1767,6 +1722,45 @@ flexible messaging model and an intuitive client API.</description>
         <artifactId>lz4-java</artifactId>
         <version>${lz4java.version}</version>
       </dependency>
+
+      <dependency>
+        <groupId>org.glassfish.jersey</groupId>
+        <artifactId>jersey-bom</artifactId>
+        <version>${jersey.version}</version>
+        <type>pom</type>
+        <scope>import</scope>
+      </dependency>
+
+      <dependency>
+        <groupId>org.glassfish.jersey.media</groupId>
+        <artifactId>jersey-media-json-jackson</artifactId>
+        <version>${jersey.version}</version>
+        <exclusions>
+          <exclusion>
+            <groupId>javax.activation</groupId>
+            <artifactId>javax.activation-api</artifactId>
+          </exclusion>
+          <exclusion>
+            <groupId>javax.xml.bind</groupId>
+            <artifactId>jaxb-api</artifactId>
+          </exclusion>
+        </exclusions>
+      </dependency>
+
+      <dependency>
+        <groupId>org.eclipse.jetty</groupId>
+        <artifactId>jetty-bom</artifactId>
+        <version>${jetty.version}</version>
+        <type>pom</type>
+        <scope>import</scope>
+      </dependency>
+      <dependency>
+        <groupId>org.eclipse.jetty.ee8</groupId>
+        <artifactId>jetty-ee8-bom</artifactId>
+        <version>${jetty.version}</version>
+        <type>pom</type>
+        <scope>import</scope>
+      </dependency>
     </dependencies>
   </dependencyManagement>
 
@@ -2694,6 +2688,7 @@ flexible messaging model and an intuitive client API.</description>
         <module>tests</module>
 
         <module>microbench</module>
+        <module>jetty-upgrade</module>
       </modules>
     </profile>
 
@@ -2752,6 +2747,7 @@ flexible messaging model and an intuitive client API.</description>
         <!-- package management releated modules (begin) -->
         <module>pulsar-package-management</module>
         <!-- package management releated modules (end) -->
+        <module>jetty-upgrade</module>
       </modules>
     </profile>
 
@@ -3071,6 +3067,24 @@ flexible messaging model and an intuitive client API.</description>
        <test.netty.leakdetection.args></test.netty.leakdetection.args>
       </properties>
     </profile>
+    <profile>
+      <id>eclipse</id>
+      <activation>
+        <property>
+          <name>m2e.version</name>
+        </property>
+      </activation>
+      <dependencyManagement>
+        <dependencies>
+          <!-- don't exclude org.apache.zookeeper:zookeeper dependency when using Eclipse/VSCode -->
+          <dependency>
+            <groupId>org.apache.pulsar</groupId>
+            <artifactId>zookeeper-with-patched-admin</artifactId>
+            <version>${project.version}</version>
+          </dependency>
+        </dependencies>
+      </dependencyManagement>
+    </profile>
   </profiles>
 
   <repositories>
diff --git a/pulsar-broker-auth-athenz/pom.xml b/pulsar-broker-auth-athenz/pom.xml
index 2eb0a52d788..9557f3b92ae 100644
--- a/pulsar-broker-auth-athenz/pom.xml
+++ b/pulsar-broker-auth-athenz/pom.xml
@@ -68,7 +68,7 @@
         <artifactId>modernizer-maven-plugin</artifactId>
         <configuration>
           <failOnViolations>true</failOnViolations>
-          <javaVersion>8</javaVersion>
+          <javaVersion>17</javaVersion>
         </configuration>
         <executions>
           <execution>
diff --git a/pulsar-broker-auth-oidc/pom.xml b/pulsar-broker-auth-oidc/pom.xml
index 08a5fa95de8..cb58ca6d0f8 100644
--- a/pulsar-broker-auth-oidc/pom.xml
+++ b/pulsar-broker-auth-oidc/pom.xml
@@ -118,7 +118,7 @@
 
     <dependency>
       <groupId>com.github.tomakehurst</groupId>
-      <artifactId>wiremock-jre8</artifactId>
+      <artifactId>wiremock-jre8-standalone</artifactId>
       <version>${wiremock.version}</version>
       <scope>test</scope>
     </dependency>
diff --git a/pulsar-broker-common/pom.xml b/pulsar-broker-common/pom.xml
index 82484191a43..6a59e69aaef 100644
--- a/pulsar-broker-common/pom.xml
+++ b/pulsar-broker-common/pom.xml
@@ -45,11 +45,6 @@
       <artifactId>guava</artifactId>
     </dependency>
 
-    <dependency>
-      <groupId>io.prometheus</groupId>
-      <artifactId>simpleclient_jetty</artifactId>
-    </dependency>
-
     <dependency>
       <groupId>io.opentelemetry</groupId>
       <artifactId>opentelemetry-api</artifactId>
@@ -75,6 +70,26 @@
       <artifactId>jjwt-jackson</artifactId>
     </dependency>
 
+    <dependency>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-server</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jetty.compression</groupId>
+      <artifactId>jetty-compression-server</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jetty.compression</groupId>
+      <artifactId>jetty-compression-gzip</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.eclipse.jetty.ee8</groupId>
+      <artifactId>jetty-ee8-servlet</artifactId>
+    </dependency>
+
     <!-- test -->
     <dependency>
       <groupId>org.bouncycastle</groupId>
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/GzipHandlerUtil.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/GzipHandlerUtil.java
index 9e980cecb79..a1e28c864b4 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/GzipHandlerUtil.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/GzipHandlerUtil.java
@@ -19,9 +19,11 @@
 package org.apache.pulsar.broker.web;
 
 import java.util.List;
+import org.eclipse.jetty.compression.gzip.GzipCompression;
+import org.eclipse.jetty.compression.server.CompressionConfig;
+import org.eclipse.jetty.compression.server.CompressionHandler;
 import org.eclipse.jetty.http.pathmap.PathSpecSet;
 import org.eclipse.jetty.server.Handler;
-import org.eclipse.jetty.server.handler.gzip.GzipHandler;
 import org.eclipse.jetty.util.IncludeExclude;
 
 public class GzipHandlerUtil {
@@ -32,10 +34,13 @@ public class GzipHandlerUtil {
             wrappedHandler = innerHandler;
         } else {
             // add GZIP handler which is active when the request contains "Accept-Encoding: gzip" header
-            GzipHandler gzipHandler = new GzipHandler();
+            CompressionHandler gzipHandler = new CompressionHandler();
+            gzipHandler.putCompression(new GzipCompression());
             gzipHandler.setHandler(innerHandler);
             if (gzipCompressionExcludedPaths != null && gzipCompressionExcludedPaths.size() > 0) {
-                gzipHandler.setExcludedPaths(gzipCompressionExcludedPaths.toArray(new String[0]));
+                CompressionConfig.Builder compressConfigBuilder = CompressionConfig.builder().defaults();
+                gzipCompressionExcludedPaths.forEach(compressConfigBuilder::compressExcludePath);
+                gzipHandler.putConfiguration("/", compressConfigBuilder.build());
             }
             wrappedHandler = gzipHandler;
         }
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/JettyRequestLogFactory.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/JettyRequestLogFactory.java
index fc88647eb49..b8a777dbe88 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/JettyRequestLogFactory.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/JettyRequestLogFactory.java
@@ -19,12 +19,14 @@
 package org.apache.pulsar.broker.web;
 
 import java.net.InetSocketAddress;
+import java.net.SocketAddress;
 import java.util.TimeZone;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicInteger;
 import lombok.extern.slf4j.Slf4j;
 import org.eclipse.jetty.io.Connection;
 import org.eclipse.jetty.io.EndPoint;
+import org.eclipse.jetty.server.ConnectionMetaData;
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.server.CustomRequestLog;
 import org.eclipse.jetty.server.ProxyConnectionFactory;
@@ -33,6 +35,7 @@ import org.eclipse.jetty.server.RequestLog;
 import org.eclipse.jetty.server.Response;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.Slf4jRequestLogWriter;
+import org.eclipse.jetty.util.Attributes;
 import org.eclipse.jetty.util.HostPort;
 import org.eclipse.jetty.util.component.ContainerLifeCycle;
 
@@ -128,14 +131,18 @@ public class JettyRequestLogFactory {
             delegate.log(request, response);
             StringBuilder sb = requestLogStringBuilder.get();
             sb.append(" [R:");
-            sb.append(request.getRemoteHost());
+            String remoteAddr = Request.getRemoteAddr(request);
+            sb.append(remoteAddr);
             sb.append(':');
-            sb.append(request.getRemotePort());
-            InetSocketAddress realRemoteAddress = lookupRealAddress(request.getHttpChannel().getRemoteAddress());
+            int remotePort = Request.getRemotePort(request);
+            sb.append(remotePort);
+
+            InetSocketAddress realRemoteAddress =
+                    lookupRealAddress(unwrap(request.getConnectionMetaData()).getRemoteSocketAddress());
             if (realRemoteAddress != null) {
                 String realRemoteHost = HostPort.normalizeHost(realRemoteAddress.getHostString());
                 int realRemotePort = realRemoteAddress.getPort();
-                if (!realRemoteHost.equals(request.getRemoteHost()) || realRemotePort != request.getRemotePort()) {
+                if (!realRemoteHost.equals(remoteAddr) || realRemotePort != remotePort) {
                     sb.append(" via ");
                     sb.append(realRemoteHost);
                     sb.append(':');
@@ -143,23 +150,26 @@ public class JettyRequestLogFactory {
                 }
             }
             sb.append("]->[L:");
-            InetSocketAddress realLocalAddress = lookupRealAddress(request.getHttpChannel().getLocalAddress());
+            InetSocketAddress realLocalAddress = lookupRealAddress(unwrap(request.getConnectionMetaData())
+                    .getLocalSocketAddress());
+            String localAddr = Request.getLocalAddr(request);
+            int localPort = Request.getLocalPort(request);
             if (realLocalAddress != null) {
                 String realLocalHost = HostPort.normalizeHost(realLocalAddress.getHostString());
                 int realLocalPort = realLocalAddress.getPort();
                 sb.append(realLocalHost);
                 sb.append(':');
                 sb.append(realLocalPort);
-                if (!realLocalHost.equals(request.getLocalAddr()) || realLocalPort != request.getLocalPort()) {
+                if (!realLocalHost.equals(localAddr) || realLocalPort != localPort) {
                     sb.append(" dst ");
-                    sb.append(request.getLocalAddr());
+                    sb.append(localAddr);
                     sb.append(':');
-                    sb.append(request.getLocalPort());
+                    sb.append(localPort);
                 }
             } else {
-                sb.append(request.getLocalAddr());
+                sb.append(localAddr);
                 sb.append(':');
-                sb.append(request.getLocalPort());
+                sb.append(localPort);
             }
             sb.append(']');
             try {
@@ -169,19 +179,27 @@ public class JettyRequestLogFactory {
             }
         }
 
-        private InetSocketAddress lookupRealAddress(InetSocketAddress socketAddress) {
-            if (socketAddress == null) {
+        private ConnectionMetaData unwrap(ConnectionMetaData connectionMetaData) {
+            if (connectionMetaData instanceof Attributes) {
+                return (ConnectionMetaData) Attributes.unwrap((Attributes) connectionMetaData);
+            }
+            return connectionMetaData;
+        }
+
+        private InetSocketAddress lookupRealAddress(SocketAddress socketAddress) {
+            if (socketAddress == null || !(socketAddress instanceof InetSocketAddress)) {
                 return null;
             }
+            InetSocketAddress inetSocketAddress = (InetSocketAddress) socketAddress;
             if (proxyProtocolRealAddressMapping.isEmpty()) {
-                return socketAddress;
+                return inetSocketAddress;
             }
-            AddressEntry entry = proxyProtocolRealAddressMapping.get(new AddressKey(socketAddress.getHostString(),
-                    socketAddress.getPort()));
+            AddressEntry entry = proxyProtocolRealAddressMapping.get(new AddressKey(inetSocketAddress.getHostString(),
+                    inetSocketAddress.getPort()));
             if (entry != null) {
                 return entry.realAddress;
             } else {
-                return socketAddress;
+                return inetSocketAddress;
             }
         }
 
@@ -221,8 +239,12 @@ public class JettyRequestLogFactory {
                     ProxyConnectionFactory.ProxyEndPoint proxyEndPoint =
                             (ProxyConnectionFactory.ProxyEndPoint) connection.getEndPoint();
                     EndPoint originalEndpoint = proxyEndPoint.unwrap();
-                    mapAddress(proxyEndPoint.getLocalAddress(), originalEndpoint.getLocalAddress(), increment);
-                    mapAddress(proxyEndPoint.getRemoteAddress(), originalEndpoint.getRemoteAddress(), increment);
+                    mapAddress((InetSocketAddress) proxyEndPoint.getLocalSocketAddress(),
+                            (InetSocketAddress) originalEndpoint.getLocalSocketAddress(),
+                            increment);
+                    mapAddress((InetSocketAddress) proxyEndPoint.getRemoteSocketAddress(),
+                            (InetSocketAddress) originalEndpoint.getRemoteSocketAddress(),
+                            increment);
                 }
             }
 
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServlet.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServlet.java
index 1ecf4c4e539..6d817bfb9bd 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServlet.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServlet.java
@@ -20,13 +20,19 @@ package org.apache.pulsar.broker.web.plugin.servlet;
 
 import com.google.common.annotations.Beta;
 import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
 
 /**
  * The additional servlet interface for support additional servlet.
  */
 @Beta
 public interface AdditionalServlet extends AutoCloseable {
+    /**
+     * The servlet implementation type enum.
+     * Currently, only {@link AdditionalServletType#JAVAX_SERVLET} is supported.
+     */
+    enum AdditionalServletType {
+        JAVAX_SERVLET
+    }
 
     /**
      * load plugin config.
@@ -43,11 +49,24 @@ public interface AdditionalServlet extends AutoCloseable {
     String getBasePath();
 
     /**
-     * Get the servlet holder.
+     * Get the servlet type that this implementation uses.
+     */
+    default AdditionalServletType getServletType() {
+        return AdditionalServletType.JAVAX_SERVLET;
+    }
+
+    /**
+     * Retrieves the servlet instance for this additional servlet implementation.
+     * <p>
+     * The returned object's type must be compatible with the servlet interface class
+     * specified by the {@link AdditionalServletType} returned from {@link #getServletType()}.
+     * For example, if {@link #getServletType()} returns {@link AdditionalServletType#JAVAX_SERVLET},
+     * the returned object must implement {@code javax.servlet.Servlet}.
+     * </p>
      *
-     * @return the servlet holder
+     * @return the servlet instance implementing the appropriate servlet interface
      */
-    ServletHolder getServletHolder();
+    Object getServletInstance();
 
     @Override
     void close();
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoader.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoader.java
index bc1f25c5af9..7fc261ecb7b 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoader.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoader.java
@@ -24,7 +24,6 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.pulsar.common.configuration.PulsarConfiguration;
 import org.apache.pulsar.common.nar.NarClassLoader;
-import org.eclipse.jetty.servlet.ServletHolder;
 
 /**
  * An additional servlet with it's classloader.
@@ -60,11 +59,22 @@ public class AdditionalServletWithClassLoader implements AdditionalServlet {
     }
 
     @Override
-    public ServletHolder getServletHolder() {
+    public AdditionalServletType getServletType() {
         ClassLoader prevClassLoader = Thread.currentThread().getContextClassLoader();
         try {
             Thread.currentThread().setContextClassLoader(classLoader);
-            return servlet.getServletHolder();
+            return servlet.getServletType();
+        } finally {
+            Thread.currentThread().setContextClassLoader(prevClassLoader);
+        }
+    }
+
+    @Override
+    public Object getServletInstance() {
+        ClassLoader prevClassLoader = Thread.currentThread().getContextClassLoader();
+        try {
+            Thread.currentThread().setContextClassLoader(classLoader);
+            return servlet.getServletInstance();
         } finally {
             Thread.currentThread().setContextClassLoader(prevClassLoader);
         }
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/metrics/JettyStatisticsCollector.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/metrics/JettyStatisticsCollector.java
new file mode 100644
index 00000000000..c4facc31515
--- /dev/null
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/metrics/JettyStatisticsCollector.java
@@ -0,0 +1,111 @@
+/*
+ * 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.pulsar.jetty.metrics;
+
+import io.prometheus.client.Collector;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jetty.server.handler.StatisticsHandler;
+
+/**
+ * Collect Prometheus metrics from jetty's org.eclipse.jetty.server.handler.StatisticsHandler.
+ *
+ * This is ported from prometheus client_java 0.16 version of JettyStatisticsCollector which is using Jetty 9.x.
+ * This supports Jetty 12.x.
+ */
+public class JettyStatisticsCollector extends Collector {
+    private final StatisticsHandler statisticsHandler;
+    private static final List<String> EMPTY_LIST = new ArrayList<String>();
+
+    public JettyStatisticsCollector(StatisticsHandler statisticsHandler) {
+        this.statisticsHandler = statisticsHandler;
+    }
+
+    @Override
+    public List<MetricFamilySamples> collect() {
+        return Arrays.asList(
+                buildCounter("jetty_requests_total", "Number of requests", statisticsHandler.getRequests()),
+                buildGauge("jetty_requests_active", "Number of requests currently active",
+                        statisticsHandler.getRequestsActive()),
+                buildGauge("jetty_requests_active_max", "Maximum number of requests that have been active at once",
+                        statisticsHandler.getRequestsActiveMax()),
+                buildGauge("jetty_request_time_max_seconds", "Maximum time spent handling requests",
+                        statisticsHandler.getRequestTimeMax() / 1000_000_000.0),
+                buildCounter("jetty_request_time_seconds_total", "Total time spent in all request handling",
+                        statisticsHandler.getRequestTimeTotal() / 1000_000_000.0),
+                buildCounter("jetty_dispatched_total", "Number of dispatches", statisticsHandler.getHandleTotal()),
+                buildGauge("jetty_dispatched_active", "Number of dispatches currently active",
+                        statisticsHandler.getHandleActive()),
+                buildGauge("jetty_dispatched_active_max", "Maximum number of active dispatches being handled",
+                        statisticsHandler.getHandleActiveMax()),
+                buildGauge("jetty_dispatched_time_max", "Maximum time spent in dispatch handling",
+                        statisticsHandler.getHandleTimeMax() / 1000_000_000.0),
+                buildCounter("jetty_dispatched_time_seconds_total", "Total time spent in dispatch handling",
+                        statisticsHandler.getHandleTimeTotal() / 1000_000_000.0),
+                buildStatusCounter(),
+                buildGauge("jetty_stats_seconds", "Time in seconds stats have been collected for",
+                        statisticsHandler.getStatisticsDuration().toNanos() / 1000_000_000.0),
+                buildCounter("jetty_responses_bytes_total", "Total number of bytes across all responses",
+                        statisticsHandler.getBytesRead() + statisticsHandler.getBytesWritten())
+        );
+    }
+
+    private static MetricFamilySamples buildGauge(String name, String help, double value) {
+        return new MetricFamilySamples(
+                name,
+                Type.GAUGE,
+                help,
+                Collections.singletonList(new MetricFamilySamples.Sample(name, EMPTY_LIST, EMPTY_LIST, value)));
+    }
+
+    private static MetricFamilySamples buildCounter(String name, String help, double value) {
+        return new MetricFamilySamples(
+                name,
+                Type.COUNTER,
+                help,
+                Collections.singletonList(new MetricFamilySamples.Sample(name, EMPTY_LIST, EMPTY_LIST, value)));
+    }
+
+    private MetricFamilySamples buildStatusCounter() {
+        String name = "jetty_responses_total";
+        return new MetricFamilySamples(
+                name,
+                Type.COUNTER,
+                "Number of requests with response status",
+                Arrays.asList(
+                        buildStatusSample(name, "1xx", statisticsHandler.getResponses1xx()),
+                        buildStatusSample(name, "2xx", statisticsHandler.getResponses2xx()),
+                        buildStatusSample(name, "3xx", statisticsHandler.getResponses3xx()),
+                        buildStatusSample(name, "4xx", statisticsHandler.getResponses4xx()),
+                        buildStatusSample(name, "5xx", statisticsHandler.getResponses5xx())
+                )
+        );
+    }
+
+    private static MetricFamilySamples.Sample buildStatusSample(String name, String status, double value) {
+        return new MetricFamilySamples.Sample(
+                name,
+                Collections.singletonList("code"),
+                Collections.singletonList(status),
+                value);
+    }
+}
\ No newline at end of file
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/metrics/package-info.java
similarity index 59%
copy from pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
copy to pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/metrics/package-info.java
index 65cc10e0d3b..53cc77e93ea 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/metrics/package-info.java
@@ -16,30 +16,4 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.pulsar.broker.web.plugin.servlet;
-
-import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
-
-public class MockAdditionalServlet implements AdditionalServlet {
-
-    @Override
-    public void loadConfig(PulsarConfiguration pulsarConfiguration) {
-
-    }
-
-    @Override
-    public String getBasePath() {
-        return null;
-    }
-
-    @Override
-    public ServletHolder getServletHolder() {
-        return null;
-    }
-
-    @Override
-    public void close() {
-        // no-op
-    }
-}
+package org.apache.pulsar.jetty.metrics;
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java
index 0ac1b78ca99..f99df06b73c 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java
@@ -68,6 +68,10 @@ public class JettySslContextFactory {
                 this.setWantClientAuth(true);
                 this.setTrustAll(true);
             }
+
+            // https://jetty.org/docs/jetty/12.1/operations-guide/protocols/index.html#ssl-sni
+            // Set to false for backwards compatibility with Jetty 9.x
+            setSniRequired(false);
         }
 
         @Override
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
index 65cc10e0d3b..492b541d6f9 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
+++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServlet.java
@@ -19,7 +19,6 @@
 package org.apache.pulsar.broker.web.plugin.servlet;
 
 import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
 
 public class MockAdditionalServlet implements AdditionalServlet {
 
@@ -34,7 +33,7 @@ public class MockAdditionalServlet implements AdditionalServlet {
     }
 
     @Override
-    public ServletHolder getServletHolder() {
+    public Object getServletInstance() {
         return null;
     }
 
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java
index 3a4664c9208..6d8f38fbb50 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java
+++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java
@@ -66,7 +66,7 @@ public class JettySslContextFactoryTest {
         PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
         sslFactory.initialize(sslConfiguration);
         sslFactory.createInternalSslContext();
-        SslContextFactory factory = JettySslContextFactory.createSslContextFactory(null,
+        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
                 sslFactory, true, null, null);
 
         ServerConnector connector = new ServerConnector(server, factory);
@@ -109,7 +109,7 @@ public class JettySslContextFactoryTest {
         PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
         sslFactory.initialize(sslConfiguration);
         sslFactory.createInternalSslContext();
-        SslContextFactory factory = JettySslContextFactory.createSslContextFactory(null,
+        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
                 sslFactory, true, null,
                 new HashSet<String>() {
                     {
@@ -162,7 +162,7 @@ public class JettySslContextFactoryTest {
         PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
         sslFactory.initialize(sslConfiguration);
         sslFactory.createInternalSslContext();
-        SslContextFactory factory = JettySslContextFactory.createSslContextFactory(null,
+        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
                 sslFactory, true,
                 new HashSet<String>() {
                     {
diff --git a/pulsar-broker/pom.xml b/pulsar-broker/pom.xml
index 5fe1d553eb4..86e1e7c43c5 100644
--- a/pulsar-broker/pom.xml
+++ b/pulsar-broker/pom.xml
@@ -169,7 +169,7 @@
 
     <dependency>
       <groupId>com.github.tomakehurst</groupId>
-      <artifactId>wiremock-jre8</artifactId>
+      <artifactId>wiremock-jre8-standalone</artifactId>
       <version>${wiremock.version}</version>
       <scope>test</scope>
     </dependency>
@@ -187,6 +187,18 @@
       <scope>test</scope>
     </dependency>
 
+    <dependency>
+      <groupId>org.eclipse.jetty.websocket</groupId>
+      <artifactId>jetty-websocket-jetty-client</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty.ee8</groupId>
+      <artifactId>jetty-ee8-proxy</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+
     <!-- zookeeper server -->
     <dependency>
        <groupId>io.dropwizard.metrics</groupId>
@@ -247,13 +259,13 @@
     </dependency>
 
     <dependency>
-      <groupId>org.eclipse.jetty</groupId>
-      <artifactId>jetty-servlet</artifactId>
+      <groupId>org.eclipse.jetty.ee8</groupId>
+      <artifactId>jetty-ee8-servlet</artifactId>
     </dependency>
 
     <dependency>
-      <groupId>org.eclipse.jetty</groupId>
-      <artifactId>jetty-servlets</artifactId>
+      <groupId>org.eclipse.jetty.ee8</groupId>
+      <artifactId>jetty-ee8-servlets</artifactId>
     </dependency>
 
     <dependency>
@@ -280,14 +292,12 @@
       <groupId>org.glassfish.jersey.test-framework</groupId>
       <artifactId>jersey-test-framework-core</artifactId>
       <scope>test</scope>
-      <version>${jersey.version}</version>
     </dependency>
 
     <dependency>
       <groupId>org.glassfish.jersey.test-framework.providers</groupId>
       <artifactId>jersey-test-framework-provider-grizzly2</artifactId>
       <scope>test</scope>
-      <version>${jersey.version}</version>
     </dependency>
 
     <dependency>
@@ -352,11 +362,6 @@
       <artifactId>simpleclient</artifactId>
     </dependency>
 
-    <dependency>
-      <groupId>io.prometheus</groupId>
-      <artifactId>simpleclient_jetty</artifactId>
-    </dependency>
-
     <dependency>
       <groupId>io.prometheus</groupId>
       <artifactId>simpleclient_hotspot</artifactId>
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
index bf5dc8a04f6..d643ed8b6fe 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
@@ -62,8 +62,8 @@ import java.util.concurrent.locks.Condition;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
+import javax.servlet.Servlet;
 import javax.servlet.ServletException;
-import javax.websocket.DeploymentException;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
@@ -197,8 +197,8 @@ import org.apache.pulsar.websocket.WebSocketProducerServlet;
 import org.apache.pulsar.websocket.WebSocketReaderServlet;
 import org.apache.pulsar.websocket.WebSocketService;
 import org.apache.pulsar.zookeeper.DefaultMetadataNodeSizeStats;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
+import org.eclipse.jetty.ee8.servlet.ServletHolder;
+import org.eclipse.jetty.ee8.websocket.server.JettyWebSocketServlet;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -1169,8 +1169,7 @@ public class PulsarService implements AutoCloseable, ShutdownService {
     private void addWebServerHandlers(WebService webService,
                                       PulsarPrometheusMetricsServlet metricsServlet,
                                       ServiceConfiguration config)
-            throws PulsarServerException, PulsarClientException, MalformedURLException, ServletException,
-            DeploymentException {
+            throws PulsarServerException, PulsarClientException, MalformedURLException, ServletException {
         Map<String, Object> attributeMap = new HashMap<>();
         attributeMap.put(WebService.ATTRIBUTE_PULSAR_NAME, this);
 
@@ -1236,9 +1235,37 @@ public class PulsarService implements AutoCloseable, ShutdownService {
                 if (additionalServlet instanceof AdditionalServletWithPulsarService) {
                     ((AdditionalServletWithPulsarService) additionalServlet).setPulsarService(this);
                 }
-                webService.addServlet(servletWithClassLoader.getBasePath(), servletWithClassLoader.getServletHolder(),
-                        config.isAuthenticationEnabled(), attributeMap);
-                LOG.info("Broker add additional servlet basePath {} ", servletWithClassLoader.getBasePath());
+                switch (servletWithClassLoader.getServletType()) {
+                    case JAVAX_SERVLET -> {
+                        Object servletInstance = servletWithClassLoader.getServletInstance();
+                        if (!(servletInstance instanceof javax.servlet.Servlet)) {
+                            LOG.error("AdditionalServletWithClassLoader {} has invalid servlet instance type {} which "
+                                            + "doesn't match {}. Skipping.", servletWithClassLoader,
+                                    servletInstance.getClass().getName(), servletWithClassLoader.getServletType());
+                            try {
+                                servletWithClassLoader.close();
+                            } catch (Exception e) {
+                                LOG.error("Failed to close servlet {}.", servletWithClassLoader, e);
+                            }
+                            continue;
+                        }
+                        ServletHolder servletHolder =
+                                new ServletHolder((Servlet) servletInstance);
+                        webService.addServlet(servletWithClassLoader.getBasePath(), servletHolder,
+                                config.isAuthenticationEnabled(), attributeMap);
+                        LOG.info("Broker add additional servlet basePath {} ", servletWithClassLoader.getBasePath());
+                    }
+                    default -> {
+                        LOG.error("AdditionalServletWithClassLoader {} has unsupported servlet type {}. Skipping.",
+                                servletWithClassLoader, servletWithClassLoader.getServletType());
+                        try {
+                            servletWithClassLoader.close();
+                        } catch (Exception e) {
+                            LOG.error("Failed to close servlet {}.", servletWithClassLoader, e);
+                        }
+                        continue;
+                    }
+                }
             }
         }
     }
@@ -1246,35 +1273,31 @@ public class PulsarService implements AutoCloseable, ShutdownService {
     private void addWebSocketServiceHandler(WebService webService,
                                             Map<String, Object> attributeMap,
                                             ServiceConfiguration config)
-            throws PulsarServerException, PulsarClientException, MalformedURLException, ServletException,
-            DeploymentException {
+            throws PulsarServerException, PulsarClientException, MalformedURLException, ServletException {
         if (config.isWebSocketServiceEnabled()) {
             // Use local broker address to avoid different IP address when using a VIP for service discovery
             this.webSocketService = new WebSocketService(null, config);
             this.webSocketService.start();
 
-            final WebSocketServlet producerWebSocketServlet = new WebSocketProducerServlet(webSocketService);
-            webService.addServlet(WebSocketProducerServlet.SERVLET_PATH,
-                    new ServletHolder(producerWebSocketServlet), true, attributeMap);
-            webService.addServlet(WebSocketProducerServlet.SERVLET_PATH_V2,
-                    new ServletHolder(producerWebSocketServlet), true, attributeMap);
-
-            final WebSocketServlet consumerWebSocketServlet = new WebSocketConsumerServlet(webSocketService);
-            webService.addServlet(WebSocketConsumerServlet.SERVLET_PATH,
-                    new ServletHolder(consumerWebSocketServlet), true, attributeMap);
-            webService.addServlet(WebSocketConsumerServlet.SERVLET_PATH_V2,
-                    new ServletHolder(consumerWebSocketServlet), true, attributeMap);
-
-            final WebSocketServlet readerWebSocketServlet = new WebSocketReaderServlet(webSocketService);
-            webService.addServlet(WebSocketReaderServlet.SERVLET_PATH,
-                    new ServletHolder(readerWebSocketServlet), true, attributeMap);
-            webService.addServlet(WebSocketReaderServlet.SERVLET_PATH_V2,
-                    new ServletHolder(readerWebSocketServlet), true, attributeMap);
-
-            final WebSocketMultiTopicConsumerServlet multiTopicConsumerWebSocketServlet =
-                    new WebSocketMultiTopicConsumerServlet(webSocketService);
-            webService.addServlet(WebSocketMultiTopicConsumerServlet.SERVLET_PATH,
-                    new ServletHolder(multiTopicConsumerWebSocketServlet), true, attributeMap);
+            addWebSocketServlet(new WebSocketProducerServlet(webSocketService), attributeMap,
+                    WebSocketProducerServlet.SERVLET_PATH, WebSocketProducerServlet.SERVLET_PATH_V2);
+
+            addWebSocketServlet(new WebSocketConsumerServlet(webSocketService), attributeMap,
+                    WebSocketConsumerServlet.SERVLET_PATH, WebSocketConsumerServlet.SERVLET_PATH_V2);
+
+            addWebSocketServlet(new WebSocketReaderServlet(webSocketService), attributeMap,
+                    WebSocketReaderServlet.SERVLET_PATH,
+                    WebSocketReaderServlet.SERVLET_PATH_V2);
+
+            addWebSocketServlet(new WebSocketMultiTopicConsumerServlet(webSocketService), attributeMap,
+                    WebSocketMultiTopicConsumerServlet.SERVLET_PATH);
+        }
+    }
+
+    private void addWebSocketServlet(JettyWebSocketServlet webSocketServlet, Map<String, Object> attributeMap,
+                                     String... servletPaths) {
+        for (String servletPath : servletPaths) {
+            webService.addWebSocketServlet(servletPath, webSocketServlet, attributeMap);
         }
     }
 
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/PulsarPrometheusMetricsServlet.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/PulsarPrometheusMetricsServlet.java
index faabad8f8fe..7593c669725 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/PulsarPrometheusMetricsServlet.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/PulsarPrometheusMetricsServlet.java
@@ -35,7 +35,7 @@ import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.pulsar.broker.PulsarService;
-import org.eclipse.jetty.server.HttpOutput;
+import org.eclipse.jetty.ee8.nested.HttpOutput;
 
 @Slf4j
 public class PulsarPrometheusMetricsServlet extends PrometheusMetricsServlet {
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/ExceptionHandler.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/ExceptionHandler.java
index 205e02ed75a..adf6123d72c 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/ExceptionHandler.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/ExceptionHandler.java
@@ -19,8 +19,6 @@
 package org.apache.pulsar.broker.web;
 
 import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletResponse;
 import javax.ws.rs.core.Response;
@@ -28,11 +26,6 @@ import lombok.extern.slf4j.Slf4j;
 import org.apache.pulsar.common.intercept.InterceptException;
 import org.apache.pulsar.common.policies.data.ErrorData;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
-import org.eclipse.jetty.http.HttpField;
-import org.eclipse.jetty.http.HttpFields;
-import org.eclipse.jetty.http.HttpHeader;
-import org.eclipse.jetty.http.HttpVersion;
-import org.eclipse.jetty.http.MetaData;
 
 /**
  *  Exception handler for handle exception.
@@ -41,29 +34,17 @@ import org.eclipse.jetty.http.MetaData;
 public class ExceptionHandler {
 
     public void handle(ServletResponse response, Exception ex) throws IOException {
+        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
         if (ex instanceof InterceptException) {
-            if (response instanceof org.eclipse.jetty.server.Response) {
-                String errorData = ObjectMapperFactory
-                        .getMapper().writer().writeValueAsString(new ErrorData(ex.getMessage()));
-                byte[] errorBytes = errorData.getBytes(StandardCharsets.UTF_8);
-                int errorCode = ((InterceptException) ex).getErrorCode();
-                HttpFields httpFields = new HttpFields();
-                HttpField httpField = new HttpField(HttpHeader.CONTENT_TYPE, "application/json;charset=utf-8");
-                httpFields.add(httpField);
-                MetaData.Response info = new MetaData.Response(HttpVersion.HTTP_1_1, errorCode, httpFields);
-                info.setHttpVersion(HttpVersion.HTTP_1_1);
-                info.setReason(errorData);
-                info.setStatus(errorCode);
-                info.setContentLength(errorBytes.length);
-                ((org.eclipse.jetty.server.Response) response).getHttpChannel().sendResponse(info,
-                        ByteBuffer.wrap(errorBytes),
-                        true);
-            } else {
-                ((HttpServletResponse) response).sendError(((InterceptException) ex).getErrorCode(),
-                        ex.getMessage());
-            }
+            byte[] errorBytes = ObjectMapperFactory
+                    .getMapper().writer().writeValueAsBytes(new ErrorData(ex.getMessage()));
+            int errorCode = ((InterceptException) ex).getErrorCode();
+            httpServletResponse.setStatus(errorCode);
+            httpServletResponse.setContentType("application/json;charset=utf-8");
+            httpServletResponse.setContentLength(errorBytes.length);
+            httpServletResponse.getOutputStream().write(errorBytes);
         } else {
-            ((HttpServletResponse) response).sendError(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
+            httpServletResponse.sendError(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
                     ex.getMessage());
         }
     }
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
index 7fa954948a8..21c99f8196f 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
@@ -19,7 +19,6 @@
 package org.apache.pulsar.broker.web;
 
 import io.prometheus.client.CollectorRegistry;
-import io.prometheus.client.jetty.JettyStatisticsCollector;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.EnumSet;
@@ -45,31 +44,33 @@ import org.apache.pulsar.broker.intercept.BrokerInterceptor;
 import org.apache.pulsar.broker.intercept.BrokerInterceptors;
 import org.apache.pulsar.common.util.PulsarSslConfiguration;
 import org.apache.pulsar.common.util.PulsarSslFactory;
+import org.apache.pulsar.jetty.metrics.JettyStatisticsCollector;
 import org.apache.pulsar.jetty.tls.JettySslContextFactory;
+import org.eclipse.jetty.ee8.nested.ContextHandler;
+import org.eclipse.jetty.ee8.nested.ResourceHandler;
+import org.eclipse.jetty.ee8.servlet.FilterHolder;
+import org.eclipse.jetty.ee8.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee8.servlet.ServletHolder;
+import org.eclipse.jetty.ee8.websocket.server.JettyWebSocketServlet;
+import org.eclipse.jetty.ee8.websocket.server.config.JettyWebSocketServletContainerInitializer;
+import org.eclipse.jetty.http.UriCompliance;
 import org.eclipse.jetty.server.ConnectionFactory;
-import org.eclipse.jetty.server.ConnectionLimit;
 import org.eclipse.jetty.server.ForwardedRequestCustomizer;
 import org.eclipse.jetty.server.Handler;
 import org.eclipse.jetty.server.HttpConfiguration;
 import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.NetworkConnectionLimit;
 import org.eclipse.jetty.server.ProxyConnectionFactory;
 import org.eclipse.jetty.server.RequestLog;
 import org.eclipse.jetty.server.SecureRequestCustomizer;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.ServerConnector;
 import org.eclipse.jetty.server.SslConnectionFactory;
-import org.eclipse.jetty.server.handler.ContextHandler;
 import org.eclipse.jetty.server.handler.ContextHandlerCollection;
 import org.eclipse.jetty.server.handler.DefaultHandler;
-import org.eclipse.jetty.server.handler.HandlerCollection;
-import org.eclipse.jetty.server.handler.RequestLogHandler;
-import org.eclipse.jetty.server.handler.ResourceHandler;
+import org.eclipse.jetty.server.handler.QoSHandler;
 import org.eclipse.jetty.server.handler.StatisticsHandler;
-import org.eclipse.jetty.servlet.FilterHolder;
-import org.eclipse.jetty.servlet.ServletContextHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.eclipse.jetty.servlets.QoSFilter;
-import org.eclipse.jetty.util.resource.Resource;
+import org.eclipse.jetty.util.resource.ResourceFactory;
 import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.glassfish.jersey.media.multipart.MultiPartFeature;
 import org.glassfish.jersey.server.ResourceConfig;
@@ -124,12 +125,14 @@ public class WebService implements AutoCloseable {
                 new WebExecutorThreadPoolStats(pulsar.getOpenTelemetry().getMeter(), webServiceExecutor);
         this.server = new Server(webServiceExecutor);
         if (config.getMaxHttpServerConnections() > 0) {
-            server.addBean(new ConnectionLimit(config.getMaxHttpServerConnections(), server));
+            server.addBean(new NetworkConnectionLimit(config.getMaxHttpServerConnections(), server));
         }
+        server.setStopTimeout(config.getBrokerShutdownTimeoutMs());
         List<ServerConnector> connectors = new ArrayList<>();
 
         Optional<Integer> port = config.getWebServicePort();
         HttpConfiguration httpConfig = new HttpConfiguration();
+        httpConfig.setUriCompliance(UriCompliance.LEGACY);
         if (config.isWebServiceTrustXForwardedFor()) {
             httpConfig.addCustomizer(new ForwardedRequestCustomizer());
         }
@@ -165,7 +168,7 @@ public class WebService implements AutoCloseable {
                                     config.getTlsCertRefreshCheckDurationSec(),
                                     TimeUnit.SECONDS);
                 }
-                SslContextFactory sslCtxFactory =
+                SslContextFactory.Server sslCtxFactory =
                         JettySslContextFactory.createSslContextFactory(config.getWebServiceTlsProvider(),
                                 this.sslFactory, config.isTlsRequireTrustedClientCertOnConnect(),
                                 config.getTlsCiphers(), config.getTlsProtocols());
@@ -178,7 +181,9 @@ public class WebService implements AutoCloseable {
                 // org.eclipse.jetty.server.AbstractConnectionFactory.getFactories contains similar logic
                 // this is needed for TLS authentication
                 if (httpConfig.getCustomizer(SecureRequestCustomizer.class) == null) {
-                    httpConfig.addCustomizer(new SecureRequestCustomizer());
+                    // disable SNI host check for backwards compatibility with Jetty 9.x
+                    boolean sniHostCheck = false;
+                    httpConfig.addCustomizer(new SecureRequestCustomizer(sniHostCheck));
                 }
                 httpsConnector = new ServerConnector(server, connectionFactories.toArray(new ConnectionFactory[0]));
                 httpsConnector.setPort(tlsPort.get());
@@ -242,12 +247,6 @@ public class WebService implements AutoCloseable {
         FilterInitializer(PulsarService pulsarService) {
             ServiceConfiguration config = pulsarService.getConfiguration();
 
-            if (config.getMaxConcurrentHttpRequests() > 0) {
-                FilterHolder filterHolder = new FilterHolder(QoSFilter.class);
-                filterHolder.setInitParameter("maxRequests", String.valueOf(config.getMaxConcurrentHttpRequests()));
-                filterHolders.add(filterHolder);
-            }
-
             if (config.isHttpRequestsLimitEnabled()) {
                 filterHolders.add(new FilterHolder(
                         new RateLimitingFilter(config.getHttpRequestsMaxPerSecond(),
@@ -358,39 +357,47 @@ public class WebService implements AutoCloseable {
         }
         filterInitializer.addFilters(servletContextHandler, requiresAuthentication);
 
-        handlers.add(servletContextHandler);
+        handlers.add(servletContextHandler.get());
+    }
+
+    public void addWebSocketServlet(String path, JettyWebSocketServlet webSocketServlet,
+                                    Map<String, Object> attributeMap) {
+        ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS);
+        servletContextHandler.setContextPath(path);
+        JettyWebSocketServletContainerInitializer.configure(servletContextHandler, null);
+        ServletHolder servletHolder = new ServletHolder(webSocketServlet);
+        servletContextHandler.addServlet(servletHolder, MATCH_ALL);
+        handlers.add(servletContextHandler.get());
     }
 
     public void addStaticResources(String basePath, String resourcePath) {
         ContextHandler capHandler = new ContextHandler();
         capHandler.setContextPath(basePath);
         ResourceHandler resHandler = new ResourceHandler();
-        resHandler.setBaseResource(Resource.newClassPathResource(resourcePath));
+        ResourceFactory resourceFactory = ResourceFactory.root();
+        resHandler.setBaseResource(resourceFactory.newClassLoaderResource(resourcePath, true));
         resHandler.setEtags(true);
         resHandler.setCacheControl(WebService.HANDLER_CACHE_CONTROL);
         capHandler.setHandler(resHandler);
-        handlers.add(capHandler);
+        handlers.add(capHandler.get());
     }
 
     public void start() throws PulsarServerException {
         try {
-            RequestLogHandler requestLogHandler = new RequestLogHandler();
             boolean showDetailedAddresses = pulsar.getConfiguration().getWebServiceLogDetailedAddresses() != null
                     ? pulsar.getConfiguration().getWebServiceLogDetailedAddresses() :
                     (pulsar.getConfiguration().isWebServiceHaProxyProtocolEnabled()
                             || pulsar.getConfiguration().isWebServiceTrustXForwardedFor());
             RequestLog requestLogger = JettyRequestLogFactory.createRequestLogger(showDetailedAddresses, server);
-            requestLogHandler.setRequestLog(requestLogger);
-            handlers.add(0, new ContextHandlerCollection());
-            handlers.add(requestLogHandler);
+            server.setRequestLog(requestLogger);
 
             ContextHandlerCollection contexts = new ContextHandlerCollection();
-            contexts.setHandlers(handlers.toArray(new Handler[handlers.size()]));
+            contexts.setHandlers(handlers);
 
             Handler handlerForContexts = GzipHandlerUtil.wrapWithGzipHandler(contexts,
                     pulsar.getConfig().getHttpServerGzipCompressionExcludedPaths());
-            HandlerCollection handlerCollection = new HandlerCollection();
-            handlerCollection.setHandlers(new Handler[] {handlerForContexts, new DefaultHandler(), requestLogHandler});
+            Handler.Collection  handlerCollection = new Handler.Sequence();
+            handlerCollection.setHandlers(handlerForContexts, new DefaultHandler());
 
             // Metrics handler
             StatisticsHandler stats = new StatisticsHandler();
@@ -402,7 +409,14 @@ public class WebService implements AutoCloseable {
                 // Already registered. Eg: in unit tests
             }
 
-            server.setHandler(stats);
+            Handler serverHandler = stats;
+            if (pulsar.getConfiguration().getMaxConcurrentHttpRequests() > 0) {
+                QoSHandler qoSHandler = new QoSHandler(serverHandler);
+                qoSHandler.setMaxRequestCount(pulsar.getConfiguration().getMaxConcurrentHttpRequests());
+                serverHandler = qoSHandler;
+            }
+            server.setHandler(serverHandler);
+
             server.start();
 
             if (httpConnector != null) {
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/PrometheusMetricsTestUtil.java b/pulsar-broker/src/test/java/org/apache/pulsar/PrometheusMetricsTestUtil.java
index 68826372b7b..50663af4f4f 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/PrometheusMetricsTestUtil.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/PrometheusMetricsTestUtil.java
@@ -32,7 +32,7 @@ import java.util.concurrent.TimeoutException;
 import org.apache.pulsar.broker.PulsarService;
 import org.apache.pulsar.broker.stats.prometheus.PrometheusMetricsGenerator;
 import org.apache.pulsar.broker.stats.prometheus.PrometheusRawMetricsProvider;
-import org.eclipse.jetty.server.HttpOutput;
+import org.eclipse.jetty.ee8.nested.HttpOutput;
 
 public class PrometheusMetricsTestUtil {
     public static void generate(PulsarService pulsar, boolean includeTopicMetrics, boolean includeConsumerMetrics,
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/BrokerAdditionalServletTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/BrokerAdditionalServletTest.java
index dc21a6a6933..3457be30107 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/BrokerAdditionalServletTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/BrokerAdditionalServletTest.java
@@ -37,8 +37,7 @@ import org.apache.pulsar.broker.web.plugin.servlet.AdditionalServletWithClassLoa
 import org.apache.pulsar.broker.web.plugin.servlet.AdditionalServletWithPulsarService;
 import org.apache.pulsar.broker.web.plugin.servlet.AdditionalServlets;
 import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.ee8.nested.Request;
 import org.mockito.Mockito;
 import org.testng.Assert;
 import org.testng.annotations.AfterClass;
@@ -75,7 +74,9 @@ public class BrokerAdditionalServletTest extends MockedPulsarServiceBaseTest {
 
         AdditionalServlet brokerAdditionalServlet = Mockito.mock(AdditionalServlet.class);
         Mockito.when(brokerAdditionalServlet.getBasePath()).thenReturn(BASE_PATH);
-        Mockito.when(brokerAdditionalServlet.getServletHolder()).thenReturn(new ServletHolder(servlet));
+        Mockito.when(brokerAdditionalServlet.getServletInstance()).thenReturn(servlet);
+        Mockito.when(brokerAdditionalServlet.getServletType())
+                .thenReturn(AdditionalServlet.AdditionalServletType.JAVAX_SERVLET);
 
         AdditionalServletWithPulsarService brokerAdditionalServletWithPulsarService =
                 new AdditionalServletWithPulsarService() {
@@ -96,8 +97,8 @@ public class BrokerAdditionalServletTest extends MockedPulsarServiceBaseTest {
                     }
 
                     @Override
-                    public ServletHolder getServletHolder() {
-                        return new ServletHolder(new WithPulsarServiceServlet(pulsarService));
+                    public Object getServletInstance() {
+                        return new WithPulsarServiceServlet(pulsarService);
                     }
 
                     @Override
@@ -146,7 +147,7 @@ public class BrokerAdditionalServletTest extends MockedPulsarServiceBaseTest {
         @Override
         public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException,
                 IOException {
-            log.info("[service] path: {}", ((Request) servletRequest).getOriginalURI());
+            log.info("[service] path: {}", ((Request) servletRequest).getHttpURI());
             String value = servletRequest.getParameterMap().get(QUERY_PARAM)[0];
             ServletOutputStream servletOutputStream = servletResponse.getOutputStream();
             servletResponse.setContentLength(value.getBytes().length);
@@ -177,7 +178,7 @@ public class BrokerAdditionalServletTest extends MockedPulsarServiceBaseTest {
         @Override
         public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException,
                 IOException {
-            log.info("[service] path: {}", ((Request) servletRequest).getOriginalURI());
+            log.info("[service] path: {}", ((Request) servletRequest).getHttpURI());
             String value = pulsarService == null ? "null" : PulsarService.class.getName();
             ServletOutputStream servletOutputStream = servletResponse.getOutputStream();
             servletResponse.setContentLength(value.getBytes().length);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiGetLastMessageIdTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiGetLastMessageIdTest.java
index 1ec8f160dfb..3a99dbc251f 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiGetLastMessageIdTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiGetLastMessageIdTest.java
@@ -29,6 +29,7 @@ import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
+import javax.servlet.ServletContext;
 import javax.ws.rs.container.AsyncResponse;
 import javax.ws.rs.container.TimeoutHandler;
 import org.apache.pulsar.broker.admin.v2.PersistentTopics;
@@ -69,7 +70,7 @@ public class AdminApiGetLastMessageIdTest extends MockedPulsarServiceBaseTest {
         admin.namespaces().createNamespace("prop/ns-abc");
         admin.namespaces().setNamespaceReplicationClusters("prop/ns-abc", Set.of("test"));
         persistentTopics = spy(PersistentTopics.class);
-        persistentTopics.setServletContext(new MockServletContext());
+        persistentTopics.setServletContext(mock(ServletContext.class));
         persistentTopics.setPulsar(pulsar);
 
         doReturn(false).when(persistentTopics).isRequestHttps();
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminTest.java
index dbff93319a0..61b255e830d 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminTest.java
@@ -45,6 +45,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import javax.servlet.ServletContext;
 import javax.ws.rs.container.AsyncResponse;
 import javax.ws.rs.core.Response;
 import javax.ws.rs.core.Response.Status;
@@ -137,7 +138,7 @@ public class AdminTest extends MockedPulsarServiceBaseTest {
         doNothing().when(properties).validateSuperUserAccess();
 
         namespaces = spy(Namespaces.class);
-        namespaces.setServletContext(new MockServletContext());
+        namespaces.setServletContext(mock(ServletContext.class));
         namespaces.setPulsar(pulsar);
         doReturn("test").when(namespaces).clientAppId();
         doReturn(Set.of("use", "usw", "usc", "global")).when(namespaces).clusters();
@@ -154,7 +155,7 @@ public class AdminTest extends MockedPulsarServiceBaseTest {
         uriField.setAccessible(true);
 
         persistentTopics = spy(PersistentTopics.class);
-        persistentTopics.setServletContext(new MockServletContext());
+        persistentTopics.setServletContext(mock(ServletContext.class));
         persistentTopics.setPulsar(pulsar);
         doReturn("test").when(persistentTopics).clientAppId();
         doReturn("persistent").when(persistentTopics).domain();
@@ -164,11 +165,11 @@ public class AdminTest extends MockedPulsarServiceBaseTest {
         doNothing().when(persistentTopics).validateAdminAccessForTenant("prop-xyz");
 
         resourceQuotas = spy(ResourceQuotas.class);
-        resourceQuotas.setServletContext(new MockServletContext());
+        resourceQuotas.setServletContext(mock(ServletContext.class));
         resourceQuotas.setPulsar(pulsar);
 
         brokerStats = spy(BrokerStats.class);
-        brokerStats.setServletContext(new MockServletContext());
+        brokerStats.setServletContext(mock(ServletContext.class));
         brokerStats.setPulsar(pulsar);
 
         doReturn(false).when(persistentTopics).isRequestHttps();
@@ -177,7 +178,7 @@ public class AdminTest extends MockedPulsarServiceBaseTest {
         doReturn(mock(AuthenticationDataHttps.class)).when(persistentTopics).clientAuthData();
 
         schemasResource = spy(SchemasResource.class);
-        schemasResource.setServletContext(new MockServletContext());
+        schemasResource.setServletContext(mock(ServletContext.class));
         schemasResource.setPulsar(pulsar);
     }
 
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/MockServletContext.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/MockServletContext.java
deleted file mode 100644
index 7ae63c500df..00000000000
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/MockServletContext.java
+++ /dev/null
@@ -1,279 +0,0 @@
-/*
- * 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.pulsar.broker.admin;
-
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.Enumeration;
-import java.util.EventListener;
-import java.util.Map;
-import java.util.Set;
-import javax.servlet.Filter;
-import javax.servlet.FilterRegistration;
-import javax.servlet.RequestDispatcher;
-import javax.servlet.Servlet;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRegistration;
-import javax.servlet.ServletRegistration.Dynamic;
-import javax.servlet.SessionCookieConfig;
-import javax.servlet.SessionTrackingMode;
-import javax.servlet.descriptor.JspConfigDescriptor;
-import org.eclipse.jetty.util.AttributesMap;
-
-public class MockServletContext extends AttributesMap implements ServletContext {
-
-    @Override
-    public String getContextPath() {
-        return null;
-    }
-
-    @Override
-    public ServletContext getContext(String uripath) {
-        return null;
-    }
-
-    @Override
-    public int getMajorVersion() {
-        return 0;
-    }
-
-    @Override
-    public int getMinorVersion() {
-        return 0;
-    }
-
-    @Override
-    public int getEffectiveMajorVersion() {
-        return 0;
-    }
-
-    @Override
-    public int getEffectiveMinorVersion() {
-        return 0;
-    }
-
-    @Override
-    public String getMimeType(String file) {
-        return null;
-    }
-
-    @Override
-    public Set<String> getResourcePaths(String path) {
-        return null;
-    }
-
-    @Override
-    public URL getResource(String path) throws MalformedURLException {
-        return null;
-    }
-
-    @Override
-    public InputStream getResourceAsStream(String path) {
-        return null;
-    }
-
-    @Override
-    public RequestDispatcher getRequestDispatcher(String path) {
-        return null;
-    }
-
-    @Override
-    public RequestDispatcher getNamedDispatcher(String name) {
-        return null;
-    }
-
-    @Override
-    @Deprecated
-    public Servlet getServlet(String name) throws ServletException {
-        return null;
-    }
-
-    @Override
-    @Deprecated
-    public Enumeration<Servlet> getServlets() {
-        return null;
-    }
-
-    @Override
-    @Deprecated
-    public Enumeration<String> getServletNames() {
-        return null;
-    }
-
-    @Override
-    public void log(String msg) {
-    }
-
-    @Override
-    @Deprecated
-    public void log(Exception exception, String msg) {
-    }
-
-    @Override
-    public void log(String message, Throwable throwable) {
-
-    }
-
-    @Override
-    public String getRealPath(String path) {
-        return null;
-    }
-
-    @Override
-    public String getServerInfo() {
-        return null;
-    }
-
-    @Override
-    public String getInitParameter(String name) {
-        return null;
-    }
-
-    @Override
-    public Enumeration<String> getInitParameterNames() {
-        return null;
-    }
-
-    @Override
-    public boolean setInitParameter(String name, String value) {
-        return false;
-    }
-
-    @Override
-    public String getServletContextName() {
-        return null;
-    }
-
-    @Override
-    public Dynamic addServlet(String servletName, String className) {
-        return null;
-    }
-
-    @Override
-    public Dynamic addServlet(String servletName, Servlet servlet) {
-        return null;
-    }
-
-    @Override
-    public Dynamic addServlet(String servletName, Class<? extends Servlet> servletClass) {
-        return null;
-    }
-
-    @Override
-    public <T extends Servlet> T createServlet(Class<T> clazz) throws ServletException {
-        return null;
-    }
-
-    @Override
-    public ServletRegistration getServletRegistration(String servletName) {
-        return null;
-    }
-
-    @Override
-    public Map<String, ? extends ServletRegistration> getServletRegistrations() {
-        return null;
-    }
-
-    @Override
-    public javax.servlet.FilterRegistration.Dynamic addFilter(String filterName, String className) {
-        return null;
-    }
-
-    @Override
-    public javax.servlet.FilterRegistration.Dynamic addFilter(String filterName, Filter filter) {
-        return null;
-    }
-
-    @Override
-    public javax.servlet.FilterRegistration.Dynamic addFilter(String filterName, Class<? extends Filter> filterClass) {
-        return null;
-    }
-
-    @Override
-    public <T extends Filter> T createFilter(Class<T> clazz) throws ServletException {
-        return null;
-    }
-
-    @Override
-    public FilterRegistration getFilterRegistration(String filterName) {
-        return null;
-    }
-
-    @Override
-    public Map<String, ? extends FilterRegistration> getFilterRegistrations() {
-        return null;
-    }
-
-    @Override
-    public SessionCookieConfig getSessionCookieConfig() {
-        return null;
-    }
-
-    @Override
-    public void setSessionTrackingModes(Set<SessionTrackingMode> sessionTrackingModes) {
-    }
-
-    @Override
-    public Set<SessionTrackingMode> getDefaultSessionTrackingModes() {
-        return null;
-    }
-
-    @Override
-    public Set<SessionTrackingMode> getEffectiveSessionTrackingModes() {
-        return null;
-    }
-
-    @Override
-    public void addListener(String className) {
-    }
-
-    @Override
-    public <T extends EventListener> void addListener(T t) {
-    }
-
-    @Override
-    public void addListener(Class<? extends EventListener> listenerClass) {
-    }
-
-    @Override
-    public <T extends EventListener> T createListener(Class<T> clazz) throws ServletException {
-        return null;
-    }
-
-    @Override
-    public JspConfigDescriptor getJspConfigDescriptor() {
-        return null;
-    }
-
-    @Override
-    public ClassLoader getClassLoader() {
-        return null;
-    }
-
-    @Override
-    public void declareRoles(String... roleNames) {
-    }
-
-    @Override
-    public String getVirtualServerName() {
-        return null;
-    }
-}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesTest.java
index 2f5b99bfd9d..da3ced47efc 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesTest.java
@@ -57,6 +57,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import javax.servlet.ServletContext;
 import javax.ws.rs.BadRequestException;
 import javax.ws.rs.ClientErrorException;
 import javax.ws.rs.WebApplicationException;
@@ -211,7 +212,7 @@ public class NamespacesTest extends MockedPulsarServiceBaseTest {
         super.internalSetup();
 
         namespaces = spy(Namespaces.class);
-        namespaces.setServletContext(new MockServletContext());
+        namespaces.setServletContext(mock(ServletContext.class));
         namespaces.setPulsar(pulsar);
         doReturn(false).when(namespaces).isRequestHttps();
         doReturn("test").when(namespaces).clientAppId();
@@ -1230,7 +1231,7 @@ public class NamespacesTest extends MockedPulsarServiceBaseTest {
         ownership.set(pulsar.getNamespaceService(), mockOwnershipCache);
         TopicName topicName = TopicName.get(testNs.getPersistentTopicName("my-topic"));
         PersistentTopics topics = spy(PersistentTopics.class);
-        topics.setServletContext(new MockServletContext());
+        topics.setServletContext(mock(ServletContext.class));
         topics.setPulsar(pulsar);
         doReturn(false).when(topics).isRequestHttps();
         doReturn("test").when(topics).clientAppId();
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesV2Test.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesV2Test.java
index 44523a16435..a4320216089 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesV2Test.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/NamespacesV2Test.java
@@ -33,6 +33,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import javax.servlet.ServletContext;
 import javax.ws.rs.core.Response;
 import javax.ws.rs.core.UriInfo;
 import org.apache.pulsar.broker.admin.v2.Namespaces;
@@ -96,7 +97,7 @@ public class NamespacesV2Test extends MockedPulsarServiceBaseTest {
         super.internalSetup();
 
         namespaces = spy(Namespaces.class);
-        namespaces.setServletContext(new MockServletContext());
+        namespaces.setServletContext(mock(ServletContext.class));
         namespaces.setPulsar(pulsar);
         doReturn(false).when(namespaces).isRequestHttps();
         doReturn("test").when(namespaces).clientAppId();
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java
index a41fad63cf3..40df6b0b5ff 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java
@@ -48,6 +48,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
+import javax.servlet.ServletContext;
 import javax.ws.rs.InternalServerErrorException;
 import javax.ws.rs.WebApplicationException;
 import javax.ws.rs.container.AsyncResponse;
@@ -149,7 +150,7 @@ public class PersistentTopicsTest extends MockedPulsarServiceBaseTest {
         conf.setTopicLevelPoliciesEnabled(false);
         super.internalSetup();
         persistentTopics = spy(PersistentTopics.class);
-        persistentTopics.setServletContext(new MockServletContext());
+        persistentTopics.setServletContext(mock(ServletContext.class));
         persistentTopics.setPulsar(pulsar);
         doReturn(false).when(persistentTopics).isRequestHttps();
         doReturn(null).when(persistentTopics).originalPrincipal();
@@ -159,7 +160,7 @@ public class PersistentTopicsTest extends MockedPulsarServiceBaseTest {
         doReturn(mock(AuthenticationDataHttps.class)).when(persistentTopics).clientAuthData();
 
         extPersistentTopics = spy(ExtPersistentTopics.class);
-        extPersistentTopics.setServletContext(new MockServletContext());
+        extPersistentTopics.setServletContext(mock(ServletContext.class));
         extPersistentTopics.setPulsar(pulsar);
         doReturn(false).when(extPersistentTopics).isRequestHttps();
         doReturn(null).when(extPersistentTopics).originalPrincipal();
@@ -169,7 +170,7 @@ public class PersistentTopicsTest extends MockedPulsarServiceBaseTest {
         doReturn(mock(AuthenticationDataHttps.class)).when(extPersistentTopics).clientAuthData();
 
         nonPersistentTopic = spy(NonPersistentTopics.class);
-        nonPersistentTopic.setServletContext(new MockServletContext());
+        nonPersistentTopic.setServletContext(mock(ServletContext.class));
         nonPersistentTopic.setPulsar(pulsar);
         namespaceResources = mock(NamespaceResources.class);
         doReturn(false).when(nonPersistentTopic).isRequestHttps();
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/ResourceGroupsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/ResourceGroupsTest.java
index b8b4f0ea160..c1fa24ae875 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/ResourceGroupsTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/ResourceGroupsTest.java
@@ -19,6 +19,7 @@
 package org.apache.pulsar.broker.admin;
 
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.fail;
@@ -27,6 +28,7 @@ import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
+import javax.servlet.ServletContext;
 import org.apache.pulsar.broker.admin.v2.ResourceGroups;
 import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest;
 import org.apache.pulsar.broker.web.RestException;
@@ -51,7 +53,7 @@ public class ResourceGroupsTest extends MockedPulsarServiceBaseTest {
     protected void setup() throws Exception {
         super.internalSetup();
         resourcegroups = spy(ResourceGroups.class);
-        resourcegroups.setServletContext(new MockServletContext());
+        resourcegroups.setServletContext(mock(ServletContext.class));
         resourcegroups.setPulsar(pulsar);
         doReturn(false).when(resourcegroups).isRequestHttps();
         doReturn("test").when(resourcegroups).clientAppId();
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java
index a5edcfa5830..c5537897e7b 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java
@@ -29,6 +29,9 @@ import static org.testng.Assert.assertNull;
 import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
 import static org.testng.Assert.fail;
+import com.fasterxml.jackson.databind.ObjectReader;
+import java.io.IOException;
+import java.io.InputStream;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.util.ArrayList;
@@ -45,6 +48,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
 import javax.ws.rs.BadRequestException;
 import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 import lombok.Cleanup;
 import lombok.extern.slf4j.Slf4j;
@@ -87,6 +91,7 @@ import org.apache.pulsar.common.policies.data.BacklogQuota;
 import org.apache.pulsar.common.policies.data.ClusterData;
 import org.apache.pulsar.common.policies.data.DelayedDeliveryPolicies;
 import org.apache.pulsar.common.policies.data.DispatchRate;
+import org.apache.pulsar.common.policies.data.ErrorData;
 import org.apache.pulsar.common.policies.data.HierarchyTopicPolicies;
 import org.apache.pulsar.common.policies.data.InactiveTopicDeleteMode;
 import org.apache.pulsar.common.policies.data.InactiveTopicPolicies;
@@ -104,6 +109,7 @@ import org.apache.pulsar.common.policies.data.TopicStats;
 import org.apache.pulsar.common.policies.data.TopicType;
 import org.apache.pulsar.common.policies.data.impl.DispatchRateImpl;
 import org.apache.pulsar.common.util.FutureUtil;
+import org.apache.pulsar.common.util.ObjectMapperFactory;
 import org.assertj.core.api.Assertions;
 import org.awaitility.Awaitility;
 import org.awaitility.reflect.WhiteboxImpl;
@@ -4043,18 +4049,37 @@ public class TopicPoliciesTest extends MockedPulsarServiceBaseTest {
                 .header("Content-Type", "application/json")
                 .post(Entity.json(backlogQuotaWithNonPolicy));
         assertEquals(response1.getStatus(), 400);
-        assertTrue(response1.getStatusInfo().getReasonPhrase().contains("policy cannot be null"));
+        assertTrue(resolveErrorReason(response1).contains("policy cannot be null"));
         // Topic level.
         Response response2 = httpClient.target(hostAndPort).path(topicPath).request()
                 .header("Content-Type", "application/json")
                 .post(Entity.json(backlogQuotaWithNonPolicy));
         assertEquals(response2.getStatus(), 400);
-        assertTrue(response2.getStatusInfo().getReasonPhrase().contains("policy cannot be null"));
+        assertTrue(resolveErrorReason(response2).contains("policy cannot be null"));
         // cleanup.
         httpClient.close();
 
     }
 
+    String resolveErrorReason(Response response) {
+        ErrorData errorData = null;
+        if (response.hasEntity() && response.getMediaType().isCompatible(MediaType.APPLICATION_JSON_TYPE)) {
+            Object responseBody = response.getEntity();
+            ObjectReader reader =
+                    ObjectMapperFactory.getMapper().getObjectMapper().reader();
+            try {
+                errorData = reader.readValue((InputStream) responseBody, ErrorData.class);
+            } catch (IOException e) {
+                // ignore
+                if (log.isDebugEnabled()) {
+                    log.debug("Failed to parse error response: {}", response);
+                }
+            }
+        }
+        return errorData != null ? errorData.reason : response.getStatusInfo().getReasonPhrase();
+    }
+
+
     @Test
     public void testSetSubRateWithSub() throws Exception {
         String topic = "persistent://" + myNamespace + "/testSetSubRateWithSub";
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/BrokerInterceptorTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/BrokerInterceptorTest.java
index 65fb7f398b3..72f1c4f5009 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/BrokerInterceptorTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/BrokerInterceptorTest.java
@@ -49,6 +49,7 @@ import org.apache.pulsar.common.nar.NarClassLoader;
 import org.apache.pulsar.common.policies.data.TenantInfoImpl;
 import org.awaitility.Awaitility;
 import org.testng.Assert;
+import org.testng.ITestResult;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
@@ -109,17 +110,18 @@ public class BrokerInterceptorTest extends ProducerConsumerBase {
 
     @Override
     protected void cleanup() throws Exception {
-        teardown();
+        teardown(null);
     }
 
     @AfterMethod(alwaysRun = true)
-    public void teardown() throws Exception {
+    public void teardown(ITestResult testResult) throws Exception {
         this.listeners.close();
-
-        verify(listener1, times(1)).close();
-        verify(listener2, times(1)).close();
-        verify(ncl1, times(1)).close();
-        verify(ncl2, times(1)).close();
+        if (testResult != null && testResult.getStatus() == ITestResult.SUCCESS) {
+            verify(listener1, times(1)).close();
+            verify(listener2, times(1)).close();
+            verify(ncl1, times(1)).close();
+            verify(ncl2, times(1)).close();
+        }
         super.internalCleanup();
     }
 
@@ -212,13 +214,13 @@ public class BrokerInterceptorTest extends ProducerConsumerBase {
 
         @Cleanup
         Producer<String> producer = pulsarClient.newProducer(Schema.STRING)
-            .topic("test-before-send-message")
-            .create();
+                .topic("test-before-send-message")
+                .create();
 
         Consumer<String> consumer = pulsarClient.newConsumer(Schema.STRING)
-            .topic("test-before-send-message")
-            .subscriptionName("test")
-            .subscribe();
+                .topic("test-before-send-message")
+                .subscriptionName("test")
+                .subscribe();
 
         assertEquals(counterBrokerInterceptor.getMessageProducedCount(), 0);
         assertEquals(counterBrokerInterceptor.getMessageDispatchCount(), 0);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/CounterBrokerInterceptor.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/CounterBrokerInterceptor.java
index a7302a907a0..a13a3671b2d 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/CounterBrokerInterceptor.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/intercept/CounterBrokerInterceptor.java
@@ -252,7 +252,11 @@ public class CounterBrokerInterceptor implements BrokerInterceptor {
         }
         if (response instanceof Response) {
             Response res = (Response) response;
-            responseList.add(new ResponseEvent(res.getHttpChannel().getRequest().getRequestURI(), res.getStatus()));
+            responseList.add(new ResponseEvent(res.getRequest().getHttpURI().getPath(), res.getStatus()));
+        } else if (response instanceof org.eclipse.jetty.ee8.nested.Response) {
+            org.eclipse.jetty.ee8.nested.Response res = (org.eclipse.jetty.ee8.nested.Response) response;
+            responseList.add(
+                    new ResponseEvent(res.getHttpChannel().getRequest().getHttpURI().getPath(), res.getStatus()));
         }
     }
 
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
index 56a08eac209..0a8f6601ad5 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
@@ -853,7 +853,7 @@ public class BrokerServiceTest extends BrokerTestBase {
 
             fail("should fail");
         } catch (Exception e) {
-            assertTrue(e.getMessage().contains("Unauthorized"));
+            assertTrue(e.getMessage().contains("Authentication required"));
         } finally {
             pulsarClient.close();
         }
@@ -916,7 +916,7 @@ public class BrokerServiceTest extends BrokerTestBase {
 
             fail("should fail");
         } catch (Exception e) {
-            assertTrue(e.getMessage().contains("Unauthorized"));
+            assertTrue(e.getMessage().contains("Authentication required"));
         } finally {
             pulsarClient.close();
         }
@@ -934,7 +934,7 @@ public class BrokerServiceTest extends BrokerTestBase {
                     .subscribe();
             fail("should fail");
         } catch (Exception e) {
-            assertTrue(e.getMessage().contains("Unauthorized"));
+            assertTrue(e.getMessage().contains("Authentication required"));
         } finally {
             pulsarClient.close();
         }
@@ -978,7 +978,7 @@ public class BrokerServiceTest extends BrokerTestBase {
                     .subscribe();
             fail("should fail");
         } catch (Exception e) {
-            assertTrue(e.getMessage().contains("Unauthorized"));
+            assertTrue(e.getMessage().contains("Authentication required"));
         } finally {
             pulsarClient.close();
         }
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/ExceptionHandlerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/ExceptionHandlerTest.java
index 3c0c2f4e687..660f4bce28c 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/ExceptionHandlerTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/ExceptionHandlerTest.java
@@ -23,8 +23,6 @@ import static org.eclipse.jetty.http.HttpStatus.PRECONDITION_FAILED_412;
 import javax.servlet.http.HttpServletResponse;
 import lombok.SneakyThrows;
 import org.apache.pulsar.common.intercept.InterceptException;
-import org.eclipse.jetty.server.HttpChannel;
-import org.eclipse.jetty.server.Response;
 import org.mockito.Mockito;
 import org.testng.annotations.Test;
 
@@ -41,21 +39,18 @@ public class ExceptionHandlerTest {
         String internal = "internal exception";
         String illegal = "illegal argument exception ";
         ExceptionHandler handler = new ExceptionHandler();
-        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class, Mockito.RETURNS_DEEP_STUBS);
         handler.handle(response, new InterceptException(PRECONDITION_FAILED_412, restriction));
-        Mockito.verify(response).sendError(PRECONDITION_FAILED_412, restriction);
+        Mockito.verify(response).setStatus(PRECONDITION_FAILED_412);
+        Mockito.verify(response.getOutputStream()).write(("{\"reason\":\"" + restriction + "\"}").getBytes());
 
         handler.handle(response, new InterceptException(INTERNAL_SERVER_ERROR_500, internal));
-        Mockito.verify(response).sendError(INTERNAL_SERVER_ERROR_500, internal);
+        Mockito.verify(response).setStatus(INTERNAL_SERVER_ERROR_500);
+        Mockito.verify(response.getOutputStream()).write(("{\"reason\":\"" + internal + "\"}").getBytes());
 
         handler.handle(response, new IllegalArgumentException(illegal));
         Mockito.verify(response).sendError(INTERNAL_SERVER_ERROR_500, illegal);
-
-        Response response2 = Mockito.mock(Response.class);
-        HttpChannel httpChannel = Mockito.mock(HttpChannel.class);
-        Mockito.when(response2.getHttpChannel()).thenReturn(httpChannel);
-        handler.handle(response2, new InterceptException(PRECONDITION_FAILED_412, restriction));
-        Mockito.verify(httpChannel).sendResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean());
     }
 
 }
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceOriginalClientIPTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceOriginalClientIPTest.java
index 42186e988a2..3fc4cdb9f18 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceOriginalClientIPTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceOriginalClientIPTest.java
@@ -27,9 +27,10 @@ import nl.altindag.console.ConsoleCaptor;
 import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest;
 import org.assertj.core.api.ThrowingConsumer;
 import org.awaitility.Awaitility;
+import org.eclipse.jetty.client.ContentResponse;
 import org.eclipse.jetty.client.HttpClient;
 import org.eclipse.jetty.client.ProxyProtocolClientConnectionFactory.V2;
-import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.http.HttpField;
 import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
@@ -45,7 +46,8 @@ public class WebServiceOriginalClientIPTest extends MockedPulsarServiceBaseTest
     @Override
     protected void setup() throws Exception {
         super.internalSetup();
-        httpClient = new HttpClient(new SslContextFactory(true));
+        httpClient = new HttpClient();
+        httpClient.setSslContextFactory(new SslContextFactory.Client(true));
         httpClient.start();
     }
 
@@ -81,7 +83,7 @@ public class WebServiceOriginalClientIPTest extends MockedPulsarServiceBaseTest
         performLoggingTest(consoleCaptor -> {
             // Send a GET request to the metrics URL
             ContentResponse response = httpClient.newRequest(metricsUrl)
-                    .header("X-Forwarded-For", "11.22.33.44:12345")
+                    .headers(hdrs -> hdrs.ensureField(new HttpField("X-Forwarded-For", "11.22.33.44:12345")))
                     .send();
 
             // Validate the response
@@ -100,7 +102,7 @@ public class WebServiceOriginalClientIPTest extends MockedPulsarServiceBaseTest
         performLoggingTest(consoleCaptor -> {
             // Send a GET request to the metrics URL
             ContentResponse response = httpClient.newRequest(metricsUrl)
-                    .header("Forwarded", "for=11.22.33.44:12345")
+                    .headers(hdrs -> hdrs.ensureField(new HttpField("Forwarded", "for=11.22.33.44:12345")))
                     .send();
 
             // Validate the response
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoaderTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoaderTest.java
index 48b1135f10c..6f7879bda4f 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoaderTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/AdditionalServletWithClassLoaderTest.java
@@ -25,10 +25,10 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNull;
+import javax.servlet.Servlet;
 import org.apache.pulsar.broker.ServiceConfiguration;
 import org.apache.pulsar.common.configuration.PulsarConfiguration;
 import org.apache.pulsar.common.nar.NarClassLoader;
-import org.eclipse.jetty.servlet.ServletHolder;
 import org.testng.annotations.Test;
 
 
@@ -55,10 +55,11 @@ public class AdditionalServletWithClassLoaderTest {
         // test getServlet
         assertEquals(wrapper.getServlet(), servlet);
         // test getServletHolder
-        ServletHolder servletHolder = new ServletHolder();
-        when(servlet.getServletHolder()).thenReturn(servletHolder);
-        assertEquals(wrapper.getServletHolder(), servletHolder);
-        verify(servlet, times(1)).getServletHolder();
+        Servlet servletInstance = mock(Servlet.class);
+        when(servlet.getServletInstance()).thenReturn(servletInstance);
+        when(servlet.getServletType()).thenReturn(AdditionalServlet.AdditionalServletType.JAVAX_SERVLET);
+        assertEquals(wrapper.getServletInstance(), servletInstance);
+        verify(servlet, times(1)).getServletInstance();
     }
 
     @Test
@@ -77,7 +78,7 @@ public class AdditionalServletWithClassLoaderTest {
             }
 
             @Override
-            public ServletHolder getServletHolder() {
+            public Object getServletInstance() {
                 assertEquals(Thread.currentThread().getContextClassLoader(), narLoader);
                 return null;
             }
@@ -101,7 +102,7 @@ public class AdditionalServletWithClassLoaderTest {
         additionalServletWithClassLoader.loadConfig(conf);
         assertEquals(Thread.currentThread().getContextClassLoader(), curClassLoader);
         // test getServletHolder
-        assertNull(additionalServletWithClassLoader.getServletHolder());
+        assertNull(additionalServletWithClassLoader.getServletInstance());
         assertEquals(Thread.currentThread().getContextClassLoader(), curClassLoader);
         // test getServlet
         assertEquals(additionalServletWithClassLoader.getServlet(), servlet);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServletWithClassLoader.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServletWithClassLoader.java
index f65a98fd3f1..cf186f9527b 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServletWithClassLoader.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/plugin/servlet/MockAdditionalServletWithClassLoader.java
@@ -20,7 +20,6 @@ package org.apache.pulsar.broker.web.plugin.servlet;
 
 import org.apache.pulsar.broker.PulsarService;
 import org.apache.pulsar.common.configuration.PulsarConfiguration;
-import org.eclipse.jetty.servlet.ServletHolder;
 
 public class MockAdditionalServletWithClassLoader implements AdditionalServletWithPulsarService{
     @Override
@@ -34,7 +33,7 @@ public class MockAdditionalServletWithClassLoader implements AdditionalServletWi
     }
 
     @Override
-    public ServletHolder getServletHolder() {
+    public Object getServletInstance() {
         return null;
     }
 
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/MockBrokerService.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/MockBrokerService.java
index b7e0ee42903..6e2db9ade01 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/MockBrokerService.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/MockBrokerService.java
@@ -23,6 +23,7 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import io.netty.bootstrap.ServerBootstrap;
 import io.netty.buffer.ByteBuf;
 import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandler;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.ChannelInitializer;
 import io.netty.channel.EventLoopGroup;
@@ -69,9 +70,10 @@ import org.apache.pulsar.common.protocol.FrameDecoderUtil;
 import org.apache.pulsar.common.protocol.PulsarDecoder;
 import org.apache.pulsar.common.protocol.schema.SchemaVersion;
 import org.apache.pulsar.common.util.netty.EventLoopUtil;
-import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.ee8.nested.AbstractHandler;
+import org.eclipse.jetty.ee8.nested.ContextHandler;
+import org.eclipse.jetty.ee8.nested.Request;
 import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.handler.AbstractHandler;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -93,7 +95,7 @@ public class MockBrokerService {
         private final Pattern multiPartPattern = Pattern.compile(".*/multi-part-.*");
 
         @Override
-        public void handle(String s, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
+        public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
                 throws IOException, ServletException {
             String responseString;
             log.info("Received HTTP request {}", baseRequest.getRequestURI());
@@ -294,7 +296,7 @@ public class MockBrokerService {
 
     public MockBrokerService() {
         server = new Server(0);
-        server.setHandler(new GenericResponseHandler());
+        server.setHandler(new ContextHandler("/", new GenericResponseHandler()));
     }
 
     public void start() {
@@ -337,7 +339,7 @@ public class MockBrokerService {
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     FrameDecoderUtil.addFrameDecoder(ch.pipeline(), maxMessageSize);
-                    ch.pipeline().addLast("handler", new MockServerCnx());
+                    ch.pipeline().addLast("handler", (ChannelHandler) new MockServerCnx());
                 }
             });
             // Bind and start to accept incoming connections.
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssConsumer.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssConsumer.java
index ab8372cbfb0..b16ec3a5f15 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssConsumer.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssConsumer.java
@@ -34,14 +34,17 @@ import org.apache.pulsar.client.impl.crypto.MessageCryptoBc;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
 import org.apache.pulsar.websocket.data.ConsumerMessage;
 import org.eclipse.jetty.websocket.api.Session;
-import org.eclipse.jetty.websocket.api.WebSocketAdapter;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen;
 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
 import org.eclipse.jetty.websocket.client.WebSocketClient;
 
 @Slf4j
-@WebSocket(maxTextMessageSize = 64 * 1024)
-public class ClientSideEncryptionWssConsumer extends WebSocketAdapter implements Closeable {
+@WebSocket
+public class ClientSideEncryptionWssConsumer implements Closeable {
 
     private Session session;
     private final CryptoKeyReader cryptoKeyReader;
@@ -69,8 +72,8 @@ public class ClientSideEncryptionWssConsumer extends WebSocketAdapter implements
     public void start() throws Exception {
         wssClient = new WebSocketClient();
         wssClient.start();
-        session = wssClient.connect(this, buildConnectURL(), new ClientUpgradeRequest()).get();
-        assertTrue(session.isOpen());
+        Session ses = wssClient.connect(this, new ClientUpgradeRequest(buildConnectURL())).get();
+        assertTrue(ses.isOpen());
     }
 
     private URI buildConnectURL() throws PulsarClientException.CryptoException {
@@ -93,24 +96,24 @@ public class ClientSideEncryptionWssConsumer extends WebSocketAdapter implements
         return msg;
     }
 
-    @Override
+    @OnWebSocketClose
     public void onWebSocketClose(int statusCode, String reason) {
         log.info("Connection closed: {} - {}", statusCode, reason);
         this.session = null;
     }
 
-    @Override
+    @OnWebSocketOpen
     public void onWebSocketConnect(Session session) {
         log.info("Got connect: {}", session);
         this.session = session;
     }
 
-    @Override
+    @OnWebSocketError
     public void onWebSocketError(Throwable cause) {
         log.error("Received an error", cause);
     }
 
-    @Override
+    @OnWebSocketMessage
     public void onWebSocketText(String text) {
 
         try {
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssProducer.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssProducer.java
index 9188bac7b37..b2a4e8a97ec 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssProducer.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ClientSideEncryptionWssProducer.java
@@ -42,15 +42,19 @@ import org.apache.pulsar.common.api.proto.CompressionType;
 import org.apache.pulsar.common.api.proto.MessageIdData;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
 import org.apache.pulsar.websocket.data.ProducerMessage;
+import org.eclipse.jetty.websocket.api.Callback;
 import org.eclipse.jetty.websocket.api.Session;
-import org.eclipse.jetty.websocket.api.WebSocketAdapter;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen;
 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
 import org.eclipse.jetty.websocket.client.WebSocketClient;
 
 @Slf4j
-@WebSocket(maxTextMessageSize = 64 * 1024)
-public class ClientSideEncryptionWssProducer extends WebSocketAdapter implements Closeable {
+@WebSocket
+public class ClientSideEncryptionWssProducer implements Closeable {
 
     private Session session;
     private volatile CompletableFuture<MessageIdData> sendFuture;
@@ -80,8 +84,8 @@ public class ClientSideEncryptionWssProducer extends WebSocketAdapter implements
     public void start() throws Exception {
         wssClient = new WebSocketClient();
         wssClient.start();
-        session = wssClient.connect(this, buildConnectURL(), new ClientUpgradeRequest()).get();
-        assertTrue(session.isOpen());
+        Session ses = wssClient.connect(this, new ClientUpgradeRequest(buildConnectURL())).get();
+        assertTrue(ses.isOpen());
     }
 
     private URI buildConnectURL() throws PulsarClientException.CryptoException {
@@ -130,7 +134,7 @@ public class ClientSideEncryptionWssProducer extends WebSocketAdapter implements
         // Do send.
         sendFuture = new CompletableFuture<>();
         String jsonMsg = ObjectMapperFactory.getMapper().writer().writeValueAsString(msg);
-        this.session.getRemote().sendString(jsonMsg);
+        this.session.sendText(jsonMsg, Callback.NOOP);
         // Wait for response.
         executor.schedule(() -> {
             synchronized (ClientSideEncryptionWssProducer.this) {
@@ -142,7 +146,7 @@ public class ClientSideEncryptionWssProducer extends WebSocketAdapter implements
         return sendFuture.get();
     }
 
-    @Override
+    @OnWebSocketClose
     public void onWebSocketClose(int statusCode, String reason) {
         log.info("Connection closed: {} - {}", statusCode, reason);
         this.session = null;
@@ -151,18 +155,18 @@ public class ClientSideEncryptionWssProducer extends WebSocketAdapter implements
         }
     }
 
-    @Override
+    @OnWebSocketOpen
     public void onWebSocketConnect(Session session) {
         log.info("Got connect: {}", session);
         this.session = session;
     }
 
-    @Override
+    @OnWebSocketError
     public void onWebSocketError(Throwable cause) {
         log.error("Received an error", cause);
     }
 
-    @Override
+    @OnWebSocketMessage
     public void onWebSocketText(String text) {
         try {
             ResponseOfSend responseOfSend =
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyAuthenticationTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyAuthenticationTest.java
index 77632fbd418..b7f45c8c196 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyAuthenticationTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyAuthenticationTest.java
@@ -127,13 +127,13 @@ public class ProxyAuthenticationTest extends ProducerConsumerBase {
         SimpleProducerSocket produceSocket = new SimpleProducerSocket();
 
         consumeClient.start();
-        ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-        Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+        ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+        Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
         log.info("Connecting to : {}", consumeUri);
 
-        ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+        ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
         produceClient.start();
-        Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+        Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
         Assert.assertTrue(consumerFuture.get().isOpen());
         Assert.assertTrue(producerFuture.get().isOpen());
 
@@ -188,13 +188,13 @@ public class ProxyAuthenticationTest extends ProducerConsumerBase {
 
         try {
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             Assert.assertTrue(consumerFuture.get().isOpen());
 
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             Assert.assertTrue(producerFuture.get().isOpen());
 
             Awaitility.await().untilAsserted(() -> Assert.assertTrue(
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyEncryptionPublishConsumeTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyEncryptionPublishConsumeTest.java
index d694e9596af..e4aa3dec7ea 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyEncryptionPublishConsumeTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyEncryptionPublishConsumeTest.java
@@ -118,15 +118,15 @@ public class ProxyEncryptionPublishConsumeTest extends ProducerConsumerBase {
         try {
             consumeClient1.start();
             consumeClient2.start();
-            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest();
-            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest();
-            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeUri, consumeRequest1);
-            Future<Session> consumerFuture2 = consumeClient2.connect(consumeSocket2, consumeUri, consumeRequest2);
+            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest(consumeUri);
+            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeRequest1);
+            Future<Session> consumerFuture2 = consumeClient2.connect(consumeSocket2, consumeRequest2);
             log.info("Connecting to : {}", consumeUri);
 
             readClient.start();
-            ClientUpgradeRequest readRequest = new ClientUpgradeRequest();
-            Future<Session> readerFuture = readClient.connect(readSocket, readUri, readRequest);
+            ClientUpgradeRequest readRequest = new ClientUpgradeRequest(readUri);
+            Future<Session> readerFuture = readClient.connect(readSocket, readRequest);
             log.info("Connecting to : {}", readUri);
 
             // let it connect
@@ -137,9 +137,9 @@ public class ProxyEncryptionPublishConsumeTest extends ProducerConsumerBase {
             // Also make sure subscriptions and reader are already created
             Thread.sleep(500);
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             produceClient.start();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             assertTrue(producerFuture.get().isOpen());
 
             Awaitility.await().untilAsserted(() -> {
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyIdleTimeoutTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyIdleTimeoutTest.java
index ed05a99a22c..b6926ae7bbb 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyIdleTimeoutTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyIdleTimeoutTest.java
@@ -95,8 +95,8 @@ public class ProxyIdleTimeoutTest extends ProducerConsumerBase {
 
         try {
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             assertThat(producerFuture).succeedsWithin(2, SECONDS);
             Session session = producerFuture.get();
             Awaitility.await().during(5, SECONDS).untilAsserted(() -> assertThat(session.isOpen()).isFalse());
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPingTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPingTest.java
index 813d978f52a..632128b37a8 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPingTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPingTest.java
@@ -97,8 +97,8 @@ public class ProxyPingTest extends ProducerConsumerBase {
 
         try {
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             assertThat(producerFuture).succeedsWithin(2, SECONDS);
             Session session = producerFuture.get();
             Awaitility.await().during(5, SECONDS).untilAsserted(() -> assertThat(session.isOpen()).isTrue());
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTest.java
index 30db9ee43be..f1255a8c97a 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTest.java
@@ -35,6 +35,7 @@ import com.google.gson.JsonPrimitive;
 import com.google.gson.reflect.TypeToken;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -68,7 +69,7 @@ import org.apache.pulsar.websocket.stats.ProxyTopicStat.ConsumerStats;
 import org.apache.pulsar.websocket.stats.ProxyTopicStat.ProducerStats;
 import org.awaitility.Awaitility;
 import org.eclipse.jetty.websocket.api.Session;
-import org.eclipse.jetty.websocket.api.UpgradeException;
+import org.eclipse.jetty.websocket.api.exceptions.UpgradeException;
 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
 import org.eclipse.jetty.websocket.client.WebSocketClient;
 import org.glassfish.jersey.client.ClientConfig;
@@ -144,15 +145,15 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
         try {
             consumeClient1.start();
             consumeClient2.start();
-            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest();
-            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest();
-            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeUri, consumeRequest1);
-            Future<Session> consumerFuture2 = consumeClient2.connect(consumeSocket2, consumeUri, consumeRequest2);
+            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest(consumeUri);
+            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeRequest1);
+            Future<Session> consumerFuture2 = consumeClient2.connect(consumeSocket2, consumeRequest2);
             log.info("Connecting to : {}", consumeUri);
 
             readClient.start();
-            ClientUpgradeRequest readRequest = new ClientUpgradeRequest();
-            Future<Session> readerFuture = readClient.connect(readSocket, readUri, readRequest);
+            ClientUpgradeRequest readRequest = new ClientUpgradeRequest(readUri);
+            Future<Session> readerFuture = readClient.connect(readSocket, readRequest);
             log.info("Connecting to : {}", readUri);
 
             // let it connect
@@ -163,9 +164,9 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
             // Also make sure subscriptions and reader are already created
             Thread.sleep(500);
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             produceClient.start();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             assertTrue(producerFuture.get().isOpen());
 
             int retry = 0;
@@ -214,16 +215,16 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             log.info("Connecting to : {}", consumeUri);
 
             // let it connect
             assertTrue(consumerFuture.get().isOpen());
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             produceClient.start();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             assertTrue(producerFuture.get().isOpen());
             // Send 30 message in total.
             produceSocket.sendMessage(20);
@@ -284,8 +285,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
         try {
             // setup a consumer
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             consumerFuture.get();
             List<String> subs = admin.topics().getSubscriptions(topic);
             assertEquals(subs.size(), 1);
@@ -314,8 +315,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumeClient1.start();
-            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest();
-            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeUri, consumeRequest1);
+            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeRequest1);
             consumerFuture1.get();
             fail("should fail: empty subscription");
         } catch (Exception e) {
@@ -341,15 +342,15 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumeClient1.start();
-            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest();
-            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeUri, consumeRequest1);
+            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeRequest1);
             consumerFuture1.get();
 
             try {
                 consumeClient2.start();
-                ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest();
+                ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest(consumeUri);
                 Future<Session> consumerFuture2 =
-                        consumeClient2.connect(consumeSocket2, consumeUri, consumeRequest2);
+                        consumeClient2.connect(consumeSocket2, consumeRequest2);
                 consumerFuture2.get();
                 fail("should fail: conflicting subscription name");
             } catch (Exception e) {
@@ -378,15 +379,15 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             produceClient1.start();
-            ClientUpgradeRequest produceRequest1 = new ClientUpgradeRequest();
-            Future<Session> producerFuture1 = produceClient1.connect(produceSocket1, produceUri, produceRequest1);
+            ClientUpgradeRequest produceRequest1 = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture1 = produceClient1.connect(produceSocket1, produceRequest1);
             producerFuture1.get();
 
             try {
                 produceClient2.start();
-                ClientUpgradeRequest produceRequest2 = new ClientUpgradeRequest();
+                ClientUpgradeRequest produceRequest2 = new ClientUpgradeRequest(produceUri);
                 Future<Session> producerFuture2 =
-                        produceClient2.connect(produceSocket2, produceUri, produceRequest2);
+                        produceClient2.connect(produceSocket2, produceRequest2);
                 producerFuture2.get();
                 fail("should fail: conflicting producer name");
             } catch (Exception e) {
@@ -434,8 +435,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
         // Create subscription
         try {
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             consumerFuture.get();
         } finally {
             stopWebSocketClient(consumeClient);
@@ -444,8 +445,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
         // Fill the backlog
         try {
             produceClient1.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient1.connect(produceSocket1, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient1.connect(produceSocket1, produceRequest);
             producerFuture.get();
             produceSocket1.sendMessage(100);
         } finally {
@@ -456,16 +457,15 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         // New producer fails to connect
         try {
+            produceClient2.setIdleTimeout(Duration.ofSeconds(5));
             produceClient2.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient2.connect(produceSocket2, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient2.connect(produceSocket2, produceRequest);
             producerFuture.get();
             fail("should fail: backlog quota exceeded");
         } catch (Exception e) {
             // Expected
             assertTrue(e.getCause() instanceof UpgradeException);
-            assertEquals(((UpgradeException) e.getCause()).getResponseStatusCode(),
-                    HttpServletResponse.SC_SERVICE_UNAVAILABLE);
         } finally {
             stopWebSocketClient(produceClient2);
             admin.topics().skipAllMessages("persistent://" + topic, subscription);
@@ -504,8 +504,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             producerFuture.get();
             fail("should fail: topic does not exist");
         } catch (Exception e) {
@@ -518,8 +518,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             consumerFuture.get();
             fail("should fail: topic does not exist");
         } catch (Exception e) {
@@ -548,8 +548,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             producerFuture.get();
             fail("should fail: producer fenced");
         } catch (Exception e) {
@@ -577,8 +577,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             producerFuture.get();
             fail("should fail: topic terminated");
         } catch (Exception e) {
@@ -621,13 +621,13 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumeClient1.start();
-            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest();
-            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeUri, consumeRequest1);
+            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeRequest1);
             log.info("Connecting to : {}", consumeUri);
 
             readClient.start();
-            ClientUpgradeRequest readRequest = new ClientUpgradeRequest();
-            Future<Session> readerFuture = readClient.connect(readSocket, readUri, readRequest);
+            ClientUpgradeRequest readRequest = new ClientUpgradeRequest(readUri);
+            Future<Session> readerFuture = readClient.connect(readSocket, readRequest);
             log.info("Connecting to : {}", readUri);
 
             assertTrue(consumerFuture1.get().isOpen());
@@ -635,9 +635,9 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
             Thread.sleep(500);
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             produceClient.start();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             // let it connect
 
             assertTrue(producerFuture.get().isOpen());
@@ -691,8 +691,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             producerFuture.get();
             produceSocket.sendMessage(100);
         } finally {
@@ -703,8 +703,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             consumerFuture.get();
         } finally {
             stopWebSocketClient(consumeClient);
@@ -735,19 +735,19 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
         try {
             consumeClient1.start();
             consumeClient2.start();
-            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest();
-            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest();
-            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeUri, consumeRequest1);
-            Future<Session> consumerFuture2 = consumeClient2.connect(consumeSocket2, consumeUri, consumeRequest2);
+            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest(consumeUri);
+            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture1 = consumeClient1.connect(consumeSocket1, consumeRequest1);
+            Future<Session> consumerFuture2 = consumeClient2.connect(consumeSocket2, consumeRequest2);
             log.info("Connecting to : {}", consumeUri);
 
             // let it connect
             assertTrue(consumerFuture1.get().isOpen());
             assertTrue(consumerFuture2.get().isOpen());
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             produceClient.start();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             assertTrue(producerFuture.get().isOpen());
             produceSocket.sendMessage(100);
 
@@ -808,20 +808,20 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
         try {
             consumeClient1.start();
             consumeClient2.start();
-            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest();
-            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest();
+            ClientUpgradeRequest consumeRequest1 = new ClientUpgradeRequest(URI.create(consumerUri));
+            ClientUpgradeRequest consumeRequest2 = new ClientUpgradeRequest(URI.create(dlqUri));
             Future<Session> consumerFuture1 =
-                    consumeClient1.connect(consumeSocket1, URI.create(consumerUri), consumeRequest1);
+                    consumeClient1.connect(consumeSocket1, consumeRequest1);
             Future<Session> consumerFuture2 =
-                    consumeClient2.connect(consumeSocket2, URI.create(dlqUri), consumeRequest2);
+                    consumeClient2.connect(consumeSocket2, consumeRequest2);
 
             assertTrue(consumerFuture1.get().isOpen());
             assertTrue(consumerFuture2.get().isOpen());
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(URI.create(producerUri));
             produceClient.start();
             Future<Session> producerFuture =
-                    produceClient.connect(produceSocket, URI.create(producerUri), produceRequest);
+                    produceClient.connect(produceSocket, produceRequest);
             assertTrue(producerFuture.get().isOpen());
 
             assertEquals(consumeSocket1.getReceivedMessagesCount(), 0);
@@ -868,15 +868,13 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumeClient.start();
-            final ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            final Future<Session> consumerFuture = consumeClient.connect(consumeSocket, URI.create(consumerUri),
-                    consumeRequest);
+            final ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(URI.create(consumerUri));
+            final Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             assertTrue(consumerFuture.get().isOpen());
 
             produceClient.start();
-            final ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
-            final Future<Session> producerFuture = produceClient.connect(produceSocket, URI.create(producerUri),
-                    produceRequest);
+            final ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(URI.create(producerUri));
+            final Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             assertTrue(producerFuture.get().isOpen());
 
             assertEquals(consumeSocket.getReceivedMessagesCount(), 0);
@@ -914,9 +912,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumerClient.start();
-            ClientUpgradeRequest consumerRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture =
-                    consumerClient.connect(consumeSocket, URI.create(consumerUri), consumerRequest);
+            ClientUpgradeRequest consumerRequest = new ClientUpgradeRequest(URI.create(consumerUri));
+            Future<Session> consumerFuture = consumerClient.connect(consumeSocket, consumerRequest);
 
             assertTrue(consumerFuture.get().isOpen());
             assertEquals(consumeSocket.getReceivedMessagesCount(), 0);
@@ -995,9 +992,8 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
 
         try {
             consumerClient.start();
-            ClientUpgradeRequest consumerRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture =
-                    consumerClient.connect(consumeSocket, URI.create(consumerUri), consumerRequest);
+            ClientUpgradeRequest consumerRequest = new ClientUpgradeRequest(URI.create(consumerUri));
+            Future<Session> consumerFuture = consumerClient.connect(consumeSocket, consumerRequest);
 
             assertTrue(consumerFuture.get().isOpen());
             assertEquals(consumeSocket.getReceivedMessagesCount(), 0);
@@ -1136,12 +1132,10 @@ public class ProxyPublishConsumeTest extends ProducerConsumerBase {
         try {
             consumerClient1.start();
             consumerClient2.start();
-            ClientUpgradeRequest consumerRequest1 = new ClientUpgradeRequest();
-            ClientUpgradeRequest consumerRequest2 = new ClientUpgradeRequest();
-            Future<Session> consumerFuture1 =
-                    consumerClient1.connect(consumeSocket1, URI.create(consumerUri1), consumerRequest1);
-            Future<Session> consumerFuture2 =
-                    consumerClient2.connect(consumeSocket2, URI.create(consumerUri2), consumerRequest2);
+            ClientUpgradeRequest consumerRequest1 = new ClientUpgradeRequest(URI.create(consumerUri1));
+            ClientUpgradeRequest consumerRequest2 = new ClientUpgradeRequest(URI.create(consumerUri2));
+            Future<Session> consumerFuture1 = consumerClient1.connect(consumeSocket1, consumerRequest1);
+            Future<Session> consumerFuture2 = consumerClient2.connect(consumeSocket2, consumerRequest2);
 
             assertTrue(consumerFuture1.get().isOpen());
             assertTrue(consumerFuture2.get().isOpen());
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
index 8ffa09de64e..1c5439d4b9e 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
@@ -24,10 +24,10 @@ import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doReturn;
 import java.net.URI;
-import java.security.GeneralSecurityException;
 import java.util.Optional;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import lombok.Cleanup;
 import org.apache.pulsar.client.api.TlsProducerConsumerBase;
 import org.apache.pulsar.client.impl.auth.AuthenticationTls;
 import org.apache.pulsar.common.util.SecurityUtility;
@@ -37,6 +37,7 @@ import org.apache.pulsar.websocket.service.ProxyServer;
 import org.apache.pulsar.websocket.service.WebSocketProxyConfiguration;
 import org.apache.pulsar.websocket.service.WebSocketServiceStarter;
 import org.awaitility.Awaitility;
+import org.eclipse.jetty.client.HttpClient;
 import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.eclipse.jetty.websocket.api.Session;
 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
@@ -95,7 +96,7 @@ public class ProxyPublishConsumeTlsTest extends TlsProducerConsumerBase {
     }
 
     @Test(timeOut = 30000)
-    public void socketTest() throws GeneralSecurityException {
+    public void socketTest() throws Exception {
         String consumerUri =
                 "wss://localhost:" + proxyServer.getListenPortHTTPS().get()
                         + "/ws/consumer/persistent/my-property/use/my-ns/my-topic/my-sub";
@@ -104,25 +105,30 @@ public class ProxyPublishConsumeTlsTest extends TlsProducerConsumerBase {
         URI consumeUri = URI.create(consumerUri);
         URI produceUri = URI.create(producerUri);
 
-        SslContextFactory sslContextFactory = new SslContextFactory();
+        SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
         sslContextFactory.setSslContext(SecurityUtility
                 .createSslContext(false, SecurityUtility.loadCertificatesFromPemFile(CA_CERT_FILE_PATH), null));
+        @Cleanup("stop")
+        HttpClient httpClient = new HttpClient();
+        httpClient.setSslContextFactory(sslContextFactory);
 
-        WebSocketClient consumeClient = new WebSocketClient(sslContextFactory);
+        @Cleanup("stop")
+        WebSocketClient consumeClient = new WebSocketClient(httpClient);
         SimpleConsumerSocket consumeSocket = new SimpleConsumerSocket();
-        WebSocketClient produceClient = new WebSocketClient(sslContextFactory);
+        @Cleanup("stop")
+        WebSocketClient produceClient = new WebSocketClient(httpClient);
 
         try {
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             log.info("Connecting to : {}", consumeUri);
             Assert.assertTrue(consumerFuture.get().isOpen());
 
             SimpleProducerSocket produceSocket = new SimpleProducerSocket();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             produceClient.start();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
 
             Assert.assertTrue(producerFuture.get().isOpen());
 
@@ -132,9 +138,6 @@ public class ProxyPublishConsumeTlsTest extends TlsProducerConsumerBase {
             });
             consumeSocket.awaitClose(1, TimeUnit.SECONDS);
             produceSocket.awaitClose(1, TimeUnit.SECONDS);
-        } catch (Throwable t) {
-            log.error(t.getMessage());
-            Assert.fail(t.getMessage());
         } finally {
             try {
                 consumeClient.stop();
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeWithoutZKTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeWithoutZKTest.java
index e923fd85613..983aa1bff77 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeWithoutZKTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeWithoutZKTest.java
@@ -96,13 +96,13 @@ public class ProxyPublishConsumeWithoutZKTest extends ProducerConsumerBase {
 
         try {
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             log.info("Connecting to : {}", consumeUri);
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             produceClient.start();
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             // let it connect
             Assert.assertTrue(consumerFuture.get().isOpen());
             Assert.assertTrue(producerFuture.get().isOpen());
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyRoleAuthTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyRoleAuthTest.java
index 0eb90b9fe36..c97dbb3812b 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyRoleAuthTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyRoleAuthTest.java
@@ -231,18 +231,18 @@ public class ProxyRoleAuthTest extends ProducerConsumerBase {
         try {
             // Connect consumer with CLIENT_TOKEN in Authorization header
             consumeClient.start();
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
             // Add JWT token authentication for WebSocket client
             consumeRequest.setHeader("Authorization", "Bearer " + CLIENT_TOKEN);
-            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeUri, consumeRequest);
+            Future<Session> consumerFuture = consumeClient.connect(consumeSocket, consumeRequest);
             log.info("Connecting consumer to: {} with CLIENT_TOKEN", consumeUri);
 
             // Connect producer with CLIENT_TOKEN in Authorization header
             produceClient.start();
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             // Add JWT token authentication for WebSocket client
             produceRequest.setHeader("Authorization", "Bearer " + CLIENT_TOKEN);
-            Future<Session> producerFuture = produceClient.connect(produceSocket, produceUri, produceRequest);
+            Future<Session> producerFuture = produceClient.connect(produceSocket, produceRequest);
             log.info("Connecting producer to: {} with CLIENT_TOKEN", produceUri);
 
             // Verify connections are established
@@ -316,11 +316,10 @@ public class ProxyRoleAuthTest extends ProducerConsumerBase {
             SimpleConsumerSocket consumeSocket = new SimpleConsumerSocket();
             unauthorizedConsumeClient.start();
 
-            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest consumeRequest = new ClientUpgradeRequest(consumeUri);
             // Use UNAUTHORIZED_TOKEN which doesn't have permissions
             consumeRequest.setHeader("Authorization", "Bearer " + UNAUTHORIZED_TOKEN);
-            Future<Session> consumerFuture = unauthorizedConsumeClient.connect(consumeSocket, consumeUri,
-                    consumeRequest);
+            Future<Session> consumerFuture = unauthorizedConsumeClient.connect(consumeSocket, consumeRequest);
 
             log.info("Attempting to connect consumer with unauthorized token to: {}", consumeUri);
 
@@ -340,11 +339,10 @@ public class ProxyRoleAuthTest extends ProducerConsumerBase {
             SimpleProducerSocket produceSocket = new SimpleProducerSocket();
             unauthorizedProduceClient.start();
 
-            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest();
+            ClientUpgradeRequest produceRequest = new ClientUpgradeRequest(produceUri);
             // Use UNAUTHORIZED_TOKEN which doesn't have permissions
             produceRequest.setHeader("Authorization", "Bearer " + UNAUTHORIZED_TOKEN);
-            Future<Session> producerFuture = unauthorizedProduceClient.connect(produceSocket, produceUri,
... 4333 lines suppressed ...