New Upstream Release - dropwizard-metrics

Ready changes

Summary

Merged new upstream version: 4.2.25 (was: 3.2.6).

Diff

diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
new file mode 100644
index 0000000..1230149
--- /dev/null
+++ b/.github/dependabot.yaml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "daily"
diff --git a/.github/workflows/close_stale.yml b/.github/workflows/close_stale.yml
new file mode 100644
index 0000000..38cf96d
--- /dev/null
+++ b/.github/workflows/close_stale.yml
@@ -0,0 +1,21 @@
+name: "Close stale issues"
+on:
+  schedule:
+  - cron: "0 0 * * *"
+permissions:
+  contents: read
+
+jobs:
+  stale:
+    permissions:
+      issues: write  # for actions/stale to close stale issues
+      pull-requests: write  # for actions/stale to close stale PRs
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9
+      with:
+        repo-token: ${{ secrets.GITHUB_TOKEN }}
+        stale-issue-message: 'This issue is stale because it has been open 180 days with no activity. Remove the "stale" label or comment or this will be closed in 14 days.'
+        stale-pr-message: 'This pull request is stale because it has been open 180 days with no activity. Remove the "stale" label or comment or this will be closed in 14 days.'
+        days-before-stale: 180
+        days-before-close: 14
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
new file mode 100644
index 0000000..a57819d
--- /dev/null
+++ b/.github/workflows/maven.yml
@@ -0,0 +1,52 @@
+name: Java CI
+on:
+  pull_request:
+    branches:
+    - release/*
+  push:
+    branches:
+    - release/*
+permissions:
+  contents: read
+jobs:
+  build:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        java_version: [11, 17, 21]
+        os:
+        - ubuntu-latest
+    env:
+      JAVA_OPTS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
+    steps:
+    - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
+      with:
+        fetch-depth: 0
+    - name: Set up JDK
+      uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4
+      with:
+        distribution: 'zulu'
+        java-version: ${{ matrix.java_version }}
+    - uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0
+      with:
+        path: ~/.m2/repository
+        key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+        restore-keys: |
+          ${{ runner.os }}-maven-
+    - name: Cache SonarCloud packages
+      uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0
+      if: ${{ env.SONAR_TOKEN != null && env.SONAR_TOKEN != '' && matrix.java_version == '17' }}
+      env:
+        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+      with:
+        path: ~/.sonar/cache
+        key: ${{ runner.os }}-sonar
+        restore-keys: ${{ runner.os }}-sonar
+    - name: Build
+      run: ./mvnw -B -V -ff -ntp install javadoc:javadoc
+    - name: Analyze with SonarCloud
+      if: ${{ env.SONAR_TOKEN != null && env.SONAR_TOKEN != '' && matrix.java_version == '17' }}
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}  # Needed to get PR information, if any
+        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+      run: ./mvnw -B -ff -ntp org.sonarsource.scanner.maven:sonar-maven-plugin:sonar
diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml
new file mode 100644
index 0000000..5591fee
--- /dev/null
+++ b/.github/workflows/qodana.yml
@@ -0,0 +1,29 @@
+---
+name: "Qodana"
+on:
+  workflow_dispatch:
+  pull_request:
+  push:
+    branches:
+      - "main"
+      - 'release/*'
+
+jobs:
+  qodana:
+    runs-on: "ubuntu-latest"
+    permissions:
+      contents: "write"
+      pull-requests: "write"
+      checks: "write"
+      security-events: "write"
+    steps:
+      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
+        with:
+          fetch-depth: 0
+      - name: 'Qodana Scan'
+        uses: JetBrains/qodana-action@v2023.2
+        env:
+          QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
+      - uses: github/codeql-action/upload-sarif@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3
+        with:
+          sarif_file: ${{ runner.temp }}/qodana/results/qodana.sarif.json
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7ea360a
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,39 @@
+name: Release
+on:
+  push:
+    branches:
+    - release/4.1.x
+    - release/4.2.x
+    - release/5.0.x
+permissions:
+  contents: read
+
+jobs:
+  release:
+    runs-on: 'ubuntu-latest'
+    env:
+      JAVA_OPTS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
+    steps:
+    - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
+    - name: Set up JDK
+      uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4
+      with:
+        java-version: 17
+        distribution: 'zulu'
+        server-id: ossrh
+        server-username: CI_DEPLOY_USERNAME
+        server-password: CI_DEPLOY_PASSWORD
+        gpg-passphrase: GPG_PASSPHRASE
+        gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+    - uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0
+      with:
+        path: ~/.m2/repository
+        key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+        restore-keys: |
+          ${{ runner.os }}-maven-
+    - name: Build and Deploy
+      run: ./mvnw -B -V -ntp -DperformRelease=true deploy
+      env:
+        CI_DEPLOY_USERNAME: ${{ secrets.CI_DEPLOY_USERNAME }}
+        CI_DEPLOY_PASSWORD: ${{ secrets.CI_DEPLOY_PASSWORD }}
+        GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
diff --git a/.github/workflows/trigger-release.yml b/.github/workflows/trigger-release.yml
new file mode 100644
index 0000000..2de94f7
--- /dev/null
+++ b/.github/workflows/trigger-release.yml
@@ -0,0 +1,48 @@
+name: Trigger Release
+on:
+  workflow_dispatch:
+    inputs:
+      releaseVersion:
+        description: 'Version of the next release'
+        required: true
+      developmentVersion:
+        description: 'Version of the next development cycle (must end in "-SNAPSHOT")'
+        required: true
+jobs:
+  trigger-release:
+    runs-on: 'ubuntu-latest'
+    permissions:
+      contents: write
+    env:
+      JAVA_OPTS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
+    steps:
+    - uses: webfactory/ssh-agent@d4b9b8ff72958532804b70bbe600ad43b36d5f2e # v0.8.0
+      with:
+        ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
+    - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      with:
+        ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
+    - name: Set up JDK
+      uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4
+      with:
+        distribution: 'temurin'
+        java-version: '17'
+        cache: 'maven'
+        server-id: ossrh
+        server-username: ${{ secrets.CI_DEPLOY_USERNAME }}
+        server-password: ${{ secrets.CI_DEPLOY_PASSWORD }}
+        gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }}
+        gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+    - name: Set up Git
+      run: |
+        git config --global committer.email "48418865+dropwizard-committers@users.noreply.github.com"
+        git config --global committer.name "Dropwizard Release Action"
+        git config --global author.email "${GITHUB_ACTOR}@users.noreply.github.com"
+        git config --global author.name "${GITHUB_ACTOR}"
+    - name: Prepare release
+      run: ./mvnw -V -B -ntp -Prelease -DreleaseVersion=${{ inputs.releaseVersion }} -DdevelopmentVersion=${{ inputs.developmentVersion }} release:prepare
+    - name: Rollback on failure
+      if: failure()
+      run: |
+        ./mvnw -B release:rollback -Prelease
+        echo "You may need to manually delete the GitHub tag, if it was created."
diff --git a/.gitignore b/.gitignore
index 6b9fbe5..fedb7d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,5 @@ bin
 .metadata
 jcstress.*
 metrics-jcstress/results/
-TODO.md
\ No newline at end of file
+TODO.md
+.mvn/wrapper/maven-wrapper.jar
diff --git a/.gitpod.yml b/.gitpod.yml
new file mode 100644
index 0000000..6db6e66
--- /dev/null
+++ b/.gitpod.yml
@@ -0,0 +1,138 @@
+## Learn more about this file at 'https://www.gitpod.io/docs/references/gitpod-yml'
+##
+## This '.gitpod.yml' file when placed at the root of a project instructs
+## Gitpod how to prepare & build the project, start development environments
+## and configure continuous prebuilds. Prebuilds when enabled builds a project
+## like a CI server so you can start coding right away - no more waiting for
+## dependencies to download and builds to finish when reviewing pull-requests
+## or hacking on something new.
+##
+## With Gitpod you can develop software from any device (even iPads) via 
+## desktop or browser based versions of VS Code or any JetBrains IDE and
+## customise it to your individual needs - from themes to extensions, you
+## have full control.
+##
+## The easiest way to try out Gitpod is install the browser extenion:
+## 'https://www.gitpod.io/docs/browser-extension' or by prefixing
+## 'https://gitpod.io#' to the source control URL of any project.
+##
+## For example: 'https://gitpod.io#https://github.com/gitpod-io/gitpod'
+
+
+## The 'image' section defines which Docker image Gitpod should use. 
+## By default, Gitpod uses a standard Docker Image called 'workspace-full'
+## which can be found at 'https://github.com/gitpod-io/workspace-images'
+##
+## Workspaces started based on this default image come pre-installed with
+## Docker, Go, Java, Node.js, C/C++, Python, Ruby, Rust, PHP as well as
+## tools such as Homebrew, Tailscale, Nginx and several more.
+##
+## If this image does not include the tools needed for your project then
+## a public Docker image or your own Docker file can be configured.
+## 
+## Learn more about images at 'https://www.gitpod.io/docs/config-docker'
+
+#image: node:buster                        # use 'https://hub.docker.com/_/node'
+#
+#image:                                    # leave image undefined if using a Dockerfile
+#  file: .gitpod.Dockerfile                # relative path to the Dockerfile from the
+#                                          # root of the project
+
+## The 'tasks' section defines how Gitpod prepares and builds this project
+## or how Gitpod can start development servers. With Gitpod, there are three
+## types of tasks:
+##
+## - before: Use this for tasks that need to run before init and before command. 
+## - init: Use this to configure prebuilds of heavy-lifting tasks such as
+##         downloading dependencies or compiling source code.
+## - command: Use this to start your database or application when the workspace starts.
+##
+## Learn more about these tasks at 'https://www.gitpod.io/docs/config-start-tasks'
+
+#tasks:
+#  - before: |
+#      # commands to execute...
+#
+#  - init: |
+#      # sudo apt-get install python3     # can be used to install operating system 
+#                                         # dependencies but these are not kept after the
+#                                         # prebuild completes thus Gitpod recommends moving
+#                                         # operating system dependency installation steps
+#                                         # to a custom Dockerfile to make prebuilds faster
+#                                         # and to keep your codebase DRY.  
+#                                         # 'https://www.gitpod.io/docs/config-docker'
+#
+#      # pip install -r requirements.txt  # install codebase dependencies
+#      # cmake                            # precompile codebase
+#
+#  - name: Web Server
+#    openMode: split-left
+#    env:
+#      WEBSERVER_PORT: 8080
+#    command: |
+#     python3 -m http.server $WEBSERVER_PORT
+#
+#  - name: Web Browser
+#    openMode: split-right
+#    env:
+#      WEBSERVER_PORT: 8080
+#    command: |
+#     gp await-port $WEBSERVER_PORT
+#     lynx `gp url`
+
+tasks:
+  - init: ./mvnw package -DskipTests
+
+## The 'ports' section defines various ports your may listen on are 
+## configured in Gitpod on an authenticated URL. By default, all ports
+## are in private visibility state.
+##
+## Learn more about ports at 'https://www.gitpod.io/docs/config-ports'
+
+#ports:
+#  - port: 8080 # alternatively configure entire ranges via '8080-8090'
+#    visibility: private # either 'public' or 'private' (default)
+#    onOpen: open-browser # either 'open-browser', 'open-preview' or 'ignore'
+
+
+## The 'vscode' section defines a list of Visual Studio Code extensions from
+## the OpenVSX.org registry to be installed upon workspace startup. OpenVSX
+## is an open alternative to the proprietary Visual Studio Code Marketplace
+## and extensions can be added by sending a pull-request with the extension
+## identifier to https://github.com/open-vsx/publish-extensions
+##
+## The identifier of an extension is always ${publisher}.${name}.
+##
+## For example: 'vscodevim.vim'
+##
+## Learn more at 'https://www.gitpod.io/docs/ides-and-editors/vscode'
+
+vscode:
+  extensions:
+    - redhat.java
+    - vscjava.vscode-java-pack
+    - lextudio.restructuredtext
+
+## The 'github' section defines configuration of continuous prebuilds
+## for GitHub repositories when the GitHub application
+## 'https://github.com/apps/gitpod-io' is installed in GitHub and granted
+## permissions to access the repository.
+##
+## Learn more at 'https://www.gitpod.io/docs/prebuilds'
+
+github: 
+  prebuilds:
+    # enable for the default branch
+    master: true
+    # enable for all branches in this repo
+    branches: true
+    # enable for pull requests coming from this repo
+    pullRequests: true
+    # enable for pull requests coming from forks
+    pullRequestsFromForks: true
+    # add a check to pull requests
+    addCheck: true
+    # add a "Review in Gitpod" button as a comment to pull requests
+    addComment: true
+    # add a "Review in Gitpod" button to the pull request's description
+    addBadge: false
diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java
new file mode 100644
index 0000000..b901097
--- /dev/null
+++ b/.mvn/wrapper/MavenWrapperDownloader.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2007-present the original author or authors.
+ *
+ * Licensed 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.
+ */
+import java.net.*;
+import java.io.*;
+import java.nio.channels.*;
+import java.util.Properties;
+
+public class MavenWrapperDownloader {
+
+    private static final String WRAPPER_VERSION = "0.5.6";
+    /**
+     * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
+     */
+    private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/"
+        + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
+
+    /**
+     * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
+     * use instead of the default one.
+     */
+    private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
+            ".mvn/wrapper/maven-wrapper.properties";
+
+    /**
+     * Path where the maven-wrapper.jar will be saved to.
+     */
+    private static final String MAVEN_WRAPPER_JAR_PATH =
+            ".mvn/wrapper/maven-wrapper.jar";
+
+    /**
+     * Name of the property which should be used to override the default download url for the wrapper.
+     */
+    private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
+
+    public static void main(String args[]) {
+        System.out.println("- Downloader started");
+        File baseDirectory = new File(args[0]);
+        System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
+
+        // If the maven-wrapper.properties exists, read it and check if it contains a custom
+        // wrapperUrl parameter.
+        File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
+        String url = DEFAULT_DOWNLOAD_URL;
+        if(mavenWrapperPropertyFile.exists()) {
+            FileInputStream mavenWrapperPropertyFileInputStream = null;
+            try {
+                mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
+                Properties mavenWrapperProperties = new Properties();
+                mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
+                url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
+            } catch (IOException e) {
+                System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
+            } finally {
+                try {
+                    if(mavenWrapperPropertyFileInputStream != null) {
+                        mavenWrapperPropertyFileInputStream.close();
+                    }
+                } catch (IOException e) {
+                    // Ignore ...
+                }
+            }
+        }
+        System.out.println("- Downloading from: " + url);
+
+        File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
+        if(!outputFile.getParentFile().exists()) {
+            if(!outputFile.getParentFile().mkdirs()) {
+                System.out.println(
+                        "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'");
+            }
+        }
+        System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
+        try {
+            downloadFileFromURL(url, outputFile);
+            System.out.println("Done");
+            System.exit(0);
+        } catch (Throwable e) {
+            System.out.println("- Error downloading");
+            e.printStackTrace();
+            System.exit(1);
+        }
+    }
+
+    private static void downloadFileFromURL(String urlString, File destination) throws Exception {
+        if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) {
+            String username = System.getenv("MVNW_USERNAME");
+            char[] password = System.getenv("MVNW_PASSWORD").toCharArray();
+            Authenticator.setDefault(new Authenticator() {
+                @Override
+                protected PasswordAuthentication getPasswordAuthentication() {
+                    return new PasswordAuthentication(username, password);
+                }
+            });
+        }
+        URL website = new URL(urlString);
+        ReadableByteChannel rbc;
+        rbc = Channels.newChannel(website.openStream());
+        FileOutputStream fos = new FileOutputStream(destination);
+        fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
+        fos.close();
+        rbc.close();
+    }
+
+}
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..346d645
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,18 @@
+# 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.
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
+wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..2cb337a
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,26 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the version of Python and other tools you might need
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.11"
+
+# Build documentation in the docs/source/ directory with Sphinx
+sphinx:
+  configuration: docs/source/conf.py
+  builder: dirhtml
+
+# If using Sphinx, optionally build your docs in additional formats such as PDF
+# formats:
+#    - pdf
+
+# Optionally declare the Python requirements required to build your docs
+python:
+  install:
+  - requirements: docs/requirements.txt
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 16c7e43..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-language: java
-
-install: echo "I trust Maven."
-
-# don't just run the tests, also run Findbugs and friends
-script: mvn verify
-
-jdk:
-  - oraclejdk8
-
-notifications:
-  email:
-    recipients:
-      - ryan@10e.us
-
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000..2dca904
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1 @@
+*       @dropwizard/metrics @dropwizard/committers
diff --git a/LICENSE b/LICENSE
index e4ba404..7cf513b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -187,7 +187,7 @@
       same "printed page" as the copyright notice for easier
       identification within third-party archives.
 
-   Copyright 2010-2012 Coda Hale and Yammer, Inc.
+   Copyright 2010-2013 Coda Hale and Yammer, Inc., 2014-2020 Dropwizard Team
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/NOTICE b/NOTICE
deleted file mode 100644
index 4fe83de..0000000
--- a/NOTICE
+++ /dev/null
@@ -1,11 +0,0 @@
-Metrics
-Copyright 2010-2013 Coda Hale and Yammer, Inc.
-
-This product includes software developed by Coda Hale and Yammer, Inc.
-
-This product includes code derived from the JSR-166 project (ThreadLocalRandom, Striped64,
-LongAdder), which was released with the following comments:
-
-    Written by Doug Lea with assistance from members of JCP JSR-166
-    Expert Group and released to the public domain, as explained at
-    http://creativecommons.org/publicdomain/zero/1.0/
diff --git a/README.md b/README.md
index 7378e9b..97e09bd 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,37 @@
-Metrics [![Build Status](https://secure.travis-ci.org/dropwizard/metrics.png)](http://travis-ci.org/dropwizard/metrics)
-[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.dropwizard.metrics/metrics-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.dropwizard.metrics/metrics-core/)
-=======
+Metrics
+======= 
+[![Java CI](https://github.com/dropwizard/metrics/workflows/Java%20CI/badge.svg)](https://github.com/dropwizard/metrics/actions?query=workflow%3A%22Java+CI%22+branch%3Arelease%2F4.2.x)
+[![Maven Central](https://img.shields.io/maven-central/v/io.dropwizard.metrics/metrics-core/4.2)](https://maven-badges.herokuapp.com/maven-central/io.dropwizard.metrics/metrics-core/)
+[![Javadoc](http://javadoc-badge.appspot.com/io.dropwizard.metrics/metrics-core.svg)](http://www.javadoc.io/doc/io.dropwizard.metrics/metrics-core)
+[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/dropwizard/metrics/tree/release/4.2.x)
 
-*Capturing JVM- and application-level metrics. So you know what's going on.*
+*πŸ“ˆ Capturing JVM- and application-level metrics. So you know what's going on.*
 
-For more information, please see [the documentation](http://dropwizard.github.io/metrics/).
+For more information, please see [the documentation](https://metrics.dropwizard.io/)
 
+### Versions
+
+| Version | Source Branch                                                                    | Documentation                                 | Status            |
+| ------- | -------------------------------------------------------------------------------- | --------------------------------------------- | ----------------- |
+| <2.2.x  | -                                                                                | -                                             | πŸ”΄ unmaintained   |
+| 2.2.x   | -                                                                                | [Docs](https://metrics.dropwizard.io/2.2.0/)  | πŸ”΄ unmaintained   |
+| 3.0.x   | [release/3.0.x branch](https://github.com/dropwizard/metrics/tree/release/3.0.x) | [Docs](https://metrics.dropwizard.io/3.0.2/)  | πŸ”΄ unmaintained   |
+| 3.1.x   | [release/3.1.x branch](https://github.com/dropwizard/metrics/tree/release/3.1.x) | [Docs](https://metrics.dropwizard.io/3.1.0/)  | πŸ”΄ unmaintained   |
+| 3.2.x   | [release/3.2.x branch](https://github.com/dropwizard/metrics/tree/release/3.2.x) | [Docs](https://metrics.dropwizard.io/3.2.3/)  | πŸ”΄ unmaintained   |
+| 4.0.x   | [release/4.0.x branch](https://github.com/dropwizard/metrics/tree/release/4.0.x) | [Docs](https://metrics.dropwizard.io/4.0.6/)  | πŸ”΄ unmaintained   |
+| 4.1.x   | [release/4.1.x branch](https://github.com/dropwizard/metrics/tree/release/4.1.x) | [Docs](https://metrics.dropwizard.io/4.1.22/) | πŸ”΄ unmaintained   |
+| 4.2.x   | [release/4.2.x branch](https://github.com/dropwizard/metrics/tree/release/4.2.x) | [Docs](https://metrics.dropwizard.io/4.2.0/)  | 🟒 maintained     |
+| 5.0.x   | [release/5.0.x branch](https://github.com/dropwizard/metrics/tree/release/5.0.x) | -                                             | 🟑 on pause       |
+
+### Future development
+
+New not-backward compatible features (for example, support for tags) will be implemented in a 5.x.x release. The release will have new Maven coordinates, a new package name and a backwards-incompatible API.
+
+Source code for 5.x.x resides in the [release/5.0.x branch](https://github.com/dropwizard/metrics/tree/release/5.0.x).
 
 License
 -------
 
-Copyright (c) 2010-2013 Coda Hale, Yammer.com
+Copyright (c) 2010-2013 Coda Hale, Yammer.com, 2014-2021 Dropwizard Team
 
 Published under Apache Software License 2.0, see LICENSE
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..15f899e
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,20 @@
+# Security Policy
+
+## Supported Versions
+
+In general, only the currently stable version is supported.
+
+| Version | Supported          |
+| ------- | ------------------ |
+| 5.0.x   | :x: (in development) |
+| 4.2.x   | :white_check_mark: |
+| < 4.2   | :x:                |
+
+## Reporting a Vulnerability
+
+To responsibly disclose security issues in Dropwizard Metrics, you can use the following contacts:
+
+* Send an email to dropwizard.committers+security@gmail.com
+* Send a direct message on Twitter: [@dropwizardio](https://twitter.com/dropwizardio)
+
+We'll be contacting you as fast as possible after receiving your message.
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index 8f23dd2..0000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-test_script:
-  - mvn package
-build: off
\ No newline at end of file
diff --git a/checkstyle.xml b/checkstyle.xml
new file mode 100644
index 0000000..2e6b434
--- /dev/null
+++ b/checkstyle.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE module PUBLIC
+        "-//Puppy Crawl//DTD Check Configuration 1.3//EN"
+        "http://www.puppycrawl.com/dtds/configuration_1_3.dtd">
+<module name="Checker">
+    <property name="severity" value="warning"/>
+    <property name="localeLanguage" value="en"/>
+
+    <module name="FileTabCharacter">
+        <property name="fileExtensions" value="java"/>
+    </module>
+
+    <module name="TreeWalker">
+
+        <!-- code cleanup -->
+        <module name="UnusedImports">
+            <property name="processJavadoc" value="true"/>
+        </module>
+        <module name="RedundantImport"/>
+        <module name="IllegalImport"/>
+        <module name="EqualsHashCode"/>
+        <module name="SimplifyBooleanExpression"/>
+        <module name="OneStatementPerLine"/>
+        <module name="UnnecessaryParentheses"/>
+        <module name="SimplifyBooleanReturn"/>
+
+        <!-- style -->
+        <module name="DefaultComesLast"/>
+        <module name="EmptyStatement"/>
+        <module name="ArrayTypeStyle"/>
+        <module name="UpperEll"/>
+        <module name="LeftCurly"/>
+        <module name="RightCurly"/>
+        <module name="EmptyStatement"/>
+        <module name="ConstantName">
+            <property name="format" value="(^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)|(^log$)"/>
+        </module>
+        <module name="LocalVariableName"/>
+        <module name="LocalFinalVariableName"/>
+        <module name="MemberName"/>
+        <module name="ClassTypeParameterName">
+            <property name="format" value="^[A-Z0-9]*$"/>
+        </module>
+        <module name="MethodTypeParameterName">
+            <property name="format" value="^[A-Z0-9]*$"/>
+        </module>
+        <module name="PackageName"/>
+        <module name="ParameterName"/>
+        <module name="StaticVariableName"/>
+        <module name="TypeName"/>
+        <module name="AvoidStarImport"/>
+
+
+        <!-- whitespace -->
+        <module name="GenericWhitespace"/>
+        <module name="NoWhitespaceBefore"/>
+        <module name="WhitespaceAfter"/>
+        <module name="NoWhitespaceAfter"/>
+        <module name="WhitespaceAround">
+            <property name="allowEmptyConstructors" value="true"/>
+            <property name="allowEmptyMethods" value="true"/>
+        </module>
+        <module name="Indentation"/>
+        <module name="MethodParamPad"/>
+        <module name="ParenPad"/>
+        <module name="TypecastParenPad"/>
+
+        <!-- locale-sensitive methods should specify locale -->
+        <module name="Regexp">
+            <!--<property name="format" value="\.to(Lower|Upper)Case\(\)"/>-->
+            <!--<property name="illegalPattern" value="true"/>-->
+            <property name="ignoreComments" value="true"/>
+        </module>
+    </module>
+</module>
\ No newline at end of file
diff --git a/debian/changelog b/debian/changelog
index e81938d..1e98b31 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+dropwizard-metrics (4.2.25-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 29 Jan 2024 01:12:41 -0000
+
 dropwizard-metrics (3.2.6-1) unstable; urgency=medium
 
   * Team upload.
diff --git a/debian/patches/drop-graphite-rabbitmq-support.patch b/debian/patches/drop-graphite-rabbitmq-support.patch
index ddb552a..15c99a8 100644
--- a/debian/patches/drop-graphite-rabbitmq-support.patch
+++ b/debian/patches/drop-graphite-rabbitmq-support.patch
@@ -4,9 +4,11 @@ Description: Do not build the Graphite RabbitMQ exporter
  metrics-graphite to work, exclude GraphiteRabbitMQ from the build.
 Last-Update: 2017-08-05
 Forwarded: no
---- a/metrics-graphite/pom.xml
-+++ b/metrics-graphite/pom.xml
-@@ -34,4 +34,18 @@
+Index: dropwizard-metrics.git/metrics-graphite/pom.xml
+===================================================================
+--- dropwizard-metrics.git.orig/metrics-graphite/pom.xml
++++ dropwizard-metrics.git/metrics-graphite/pom.xml
+@@ -92,4 +92,18 @@
              <scope>test</scope>
          </dependency>
      </dependencies>
diff --git a/docs/pom.xml b/docs/pom.xml
index 205b0fc..1a94cda 100644
--- a/docs/pom.xml
+++ b/docs/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>docs</artifactId>
@@ -14,7 +14,9 @@
     <properties>
         <jar.skipIfEmpty>true</jar.skipIfEmpty>
         <mpir.skip>true</mpir.skip>
+        <maven.install.skip>true</maven.install.skip>
         <maven.deploy.skip>true</maven.deploy.skip>
+        <javaModuleName>com.codahale.metrics.docs</javaModuleName>
     </properties>
 
     <build>
@@ -28,7 +30,7 @@
             <plugin>
                 <groupId>org.codehaus.mojo</groupId>
                 <artifactId>build-helper-maven-plugin</artifactId>
-                <version>1.9.1</version>
+                <version>3.5.0</version>
                 <executions>
                     <execution>
                         <id>parse-version</id>
@@ -42,6 +44,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-resources-plugin</artifactId>
+                <version>3.3.1</version>
                 <executions>
                     <execution>
                         <id>process-resources</id>
@@ -70,6 +73,7 @@
                 <configuration>
                     <sourceDirectory>${project.build.directory}/source</sourceDirectory>
                 </configuration>
+                <version>2.10.0</version>
             </plugin>
         </plugins>
     </reporting>
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..2a891db
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1 @@
+sphinx<7
diff --git a/docs/source/_themes/metrics/theme.conf b/docs/source/_themes/metrics/theme.conf
index d52f9a9..1b03e91 100644
--- a/docs/source/_themes/metrics/theme.conf
+++ b/docs/source/_themes/metrics/theme.conf
@@ -14,4 +14,4 @@ landing_logo = logo.png
 landing_logo_width = 150px
 github_page = https://github.com/yay
 mailing_list = http://groups.google.com/yay
-apidocs = https://dropwizard.github.io/metrics/
+apidocs = https://www.javadoc.io/doc/io.dropwizard.metrics/metrics-core/
diff --git a/docs/source/about/contributors.rst b/docs/source/about/contributors.rst
index 0a62d1b..db8b2d4 100644
--- a/docs/source/about/contributors.rst
+++ b/docs/source/about/contributors.rst
@@ -6,58 +6,83 @@ Contributors
 
 Many, many thanks to:
 
+* `Aaron Stockmeister <https://github.com/stockmaj>`_
 * `Alan Woodward <https://github.com/romseygeek>`_
-* `Alexander Reelsen <https://github.com/spinscale>`_
+* `Aleksandr Podkutin <https://github.com/apodkutin>`_
 * `Alex Lambert <https://github.com/alambert>`_
+* `Alexander Eyers-Taylor <https://github.com/alexet>`_
+* `Alexander Reelsen <https://github.com/spinscale>`_
+* `Alexey Nezhdanov <https://github.com/snakeru>`_
+* `Andreas Gebhardt <https://github.com/agebhar1>`_
+* `Andrew Fitzgerald <https://github.com/fitzoh>`_
+* `Andrey Rodionov <https://github.com/dernasherbrezon>`_ 
 * `Anil V <https://github.com/avaitla>`_
 * `Anthony Dahanne <https://github.com/anthonydahanne>`_
 * `Antonin Stefanutti <https://github.com/astefanutti>`_
+* `apirom9 <https://github.com/apirom9>`_
 * `Artem Prigoda <https://github.com/arteam>`_
-* `Bartosz KrasiΕ„ski <https://github.com/krasinski>`_
+* `Ashley Sole <https://github.com/ashisamazin>`_
 * `Bart Prokop <https://github.com/bartprokop>`_
+* `Bartosz KrasiΕ„ski <https://github.com/krasinski>`_
 * `Basil James Whitehouse III <https://github.com/basil3whitehouse>`_
-* `Benjamin Gehrels <https://github.com/BGehrels>`_
 * `Ben Tatham <https://github.com/bentatham>`_
-* `Bogdan Storozhuk <https://github.com/storozhukBM>`_
+* `Benjamin Gehrels <https://github.com/BGehrels>`_
+* `Bohdan Storozhuk <https://github.com/storozhukBM>`_
 * `Brenden Matthews <https://github.com/brndnmtthws>`_
 * `Brian  <https://github.com/codelotus>`_
 * `Brian Roberts <https://github.com/flicken>`_
 * `Bruce Mitchener <https://github.com/waywardmonkeys>`_
+* `C. Scott Andreas <https://github.com/cscotta>`_
+* `Carter Kozak <https://github.com/carterkozak>`_
 * `cburroughs <https://github.com/cburroughs>`_
 * `ceetav <https://github.com/ceetav>`_
 * `Charles Care <https://github.com/ccare>`_
 * `Chris Birchall <https://github.com/cb372>`_
+* `Chris Rohr <https://github.com/chrisrohr>`_
 * `Christopher Gray <https://github.com/chrisgray>`_
 * `Christopher Swenson <https://github.com/swenson>`_
 * `ciamac <https://github.com/ciamac>`_
 * `Coda Hale <https://github.com/codahale>`_
 * `Collin Van Dyck <https://github.com/collinvandyck>`_
 * `Corentin Chary <https://github.com/iksaif>`_
-* `C. Scott Andreas <https://github.com/cscotta>`_
 * `Dag Liodden <https://github.com/daggerrz>`_
 * `Dale Wijnand <https://github.com/dwijnand>`_
 * `Dan Brown <https://github.com/jdanbrown>`_
 * `Dan Everton <https://github.com/deverton>`_
-* `Daniel James <https://github.com/dwhjames>`_
 * `Dan Revel <https://github.com/nopolabs>`_
+* `Daniel James <https://github.com/dwhjames>`_
+* `DarkJenum <https://github.com/DarkJenum>`_
+* `David Hatanian <https://github.com/dhatanian>`_
 * `David M. Karr <https://github.com/davidmichaelkarr>`_
+* `David Pursehouse <https://github.com/dpursehouse>`_
 * `David Schlosnagle <https://github.com/schlosna>`_
 * `David Sutherland <https://github.com/djsutho>`_
+* `Denny Abraham Cheriyan <https://github.com/dennyac>`_
 * `Diwaker Gupta <https://github.com/diwakergupta>`_
 * `Drew Stephens <https://github.com/dinomite>`_
 * `Eduard Martinescu <https://github.com/Arvoreen>`_
 * `Edwin Shin <https://github.com/eddies>`_
 * `Erik van Oosten <https://github.com/erikvanoosten>`_
+* `Erlend Hamnaberg <https://github.com/hamnis>`_
 * `Evan Jones <https://github.com/evanj>`_
+* `Fabien Renaud <https://github.com/fabienrenaud>`_
 * `Fabrizio Cannizzo <https://github.com/smartrics>`_
+* `Fokko Driesprong <https://github.com/Fokko>`_
 * `François Beausoleil <https://github.com/francois>`_
+* `Fred Deschenes <https://github.com/FredDeschenes>`_
+* `g-fresh <https://github.com/g-fresh>`_
 * `Gabor Arki <https://github.com/arkigabor>`_
 * `George Spalding <https://github.com/georgespalding>`_
 * `Gerolf Seitz <https://github.com/gseitz>`_
 * `gilbode <https://github.com/gilbode>`_
+* `goraxe <https://github.com/goraxe>`_
 * `Greg Bowyer <https://github.com/GregBowyer>`_
+* `Gregory Haase <https://github.com/ghaase>`_
+* `Guillermo Calvo <https://github.com/guillermocalvo>`_
 * `Gunnar Ahlberg <https://github.com/gunnarahlberg>`_
 * `Henri Tremblay <https://github.com/henri-tremblay>`_
+* `HervΓ© Boutemy <https://github.com/hboutemy>`_
+* `Himangi Saraogi <https://github.com/hsaraogi>`_
 * `ho3rexqj <https://github.com/ho3rexqj>`_
 * `Hussein Elsayed <https://github.com/husseincoder>`_
 * `Ian Strachan <https://github.com/ianestrachan>`_
@@ -77,44 +102,70 @@ Many, many thanks to:
 * `Jens Schauder <https://github.com/schauder>`_
 * `Jesper Blomquist <https://github.com/jebl01>`_
 * `Jesse Eichar <https://github.com/jesseeichar>`_
+* `jkytomaki <https://github.com/jkytomaki>`_
 * `Jochen Schalanda <https://github.com/joschi>`_
 * `Joe Ellis <https://github.com/ellisjoe>`_
 * `Joel Takvorian <https://github.com/jotak>`_
-* `John-John Tedro <https://github.com/udoprog>`_
+* `John Karp <https://github.com/john-karp>`_
 * `John Wang <https://github.com/javasoze>`_
+* `John Watson <https://github.com/jkwatson>`_
+* `John-John Tedro <https://github.com/udoprog>`_
+* `Jonathan Haber <https://github.com/jhaber>`_
 * `Jordan Focht <https://github.com/jfocht>`_
 * `Juha SyrjΓ€lΓ€ <https://github.com/jsyrjala>`_
 * `Julio Lopez <https://github.com/julio-maginatics>`_
 * `Justin Plock <https://github.com/jplock>`_
+* `JΓΆrg Fischer <https://github.com/g-fresh>`_
+* `Kasa <https://github.com/raskasa>`_
+* `KaseiFR <https://github.com/KaseiFR>`_
+* `Keir Lawson <https://github.com/keirlawson>`_
 * `Kevin Clark <https://github.com/kevinclark>`_
+* `Kevin Herron <https://github.com/kevinherron>`_
 * `Kevin Menard <https://github.com/nirvdrum>`_
 * `Kevin Yeh <https://github.com/kyeah>`_
+* `keze <https://github.com/keze>`_
 * `konnik <https://github.com/konnik>`_
+* `krasinski <https://github.com/krasinski>`_
 * `Larry Shatzer, Jr. <https://github.com/larrys>`_
 * `Luke Amdor <https://github.com/rubbish>`_
+* `Magnus Reftel <https://github.com/reftel>`_
 * `Mahesh Tiyyagura <https://github.com/tmahesh>`_
+* `Marcin L <https://github.com/the-thing>`_
 * `Mark Menard <https://github.com/MarkMenard>`_
 * `Marlon Bernardes <https://github.com/marlonbernardes>`_
-* `MΓ₯rten Gustafson <https://github.com/chids>`_
 * `Martin JΓΆhren <https://github.com/matlockx>`_
 * `Martin Traverso <https://github.com/martint>`_
+* `Mateusz Zakarczemny <https://github.com/Matzz>`_
 * `Matheus Cabral <https://github.com/mcgois>`_
 * `Matt Abrams <https://github.com/abramsm>`_
+* `Matt Veitas <https://github.com/mveitas>`_
 * `Matthew Gilliard <https://github.com/mjg123>`_
 * `Matthew O'Connor <https://github.com/oconnor0>`_
-* `Matt Veitas <https://github.com/mveitas>`_
+* `Matthias Wiedemann <https://github.com/mwiede>`_
+* `Michael Golahi <https://github.com/mgolahi>`_
+* `Michael Peyton Jones <https://github.com/michaelpj>`_
+* `Michael Vorburger <https://github.com/vorburger>`_
 * `MichaΕ‚ Minicki <https://github.com/martel>`_
 * `Miikka Koskinen <https://github.com/miikka>`_
+* `Mike Gilbode <https://github.com/gilbode>`_
+* `Mike Minicki <https://github.com/martel>`_
+* `MΓ₯rten Gustafson <https://github.com/chids>`_
 * `Neil Prosser <https://github.com/neilprosser>`_
 * `Nick Babcock <https://github.com/nickbabcock>`_
 * `Nick Telford <https://github.com/nicktelford>`_
+* `Nikolai Mazurkin <https://github.com/mazurkin>`_
 * `Norbert Potocki <https://github.com/norbertpotocki>`_
 * `Pablo Fernandez <https://github.com/fernandezpablo85>`_
 * `Patryk Najda <https://github.com/patrox>`_
 * `Paul Brown <https://github.com/prb>`_
 * `Paul Doran <https://github.com/dorzey>`_
+* `Paul Oliver <https://github.com/puzza007>`_
 * `Paul Sanwald <https://github.com/pcsanwald>`_
+* `Peter Steiner <https://github.com/pe-st>`_
+* `Philip Dakowitz <https://github.com/philmtd>`_
+* `Philip Helger <https://github.com/phax>`_
 * `Philipp Hauer <https://github.com/phauer>`_
+* `Rahul Ravindran <https://github.com/rahulravindran0108>`_
 * `Raman Gupta <https://github.com/rocketraman>`_
 * `Realbot <https://github.com/realbot>`_
 * `Robby Walker <https://github.com/robbywalker>`_
@@ -124,28 +175,41 @@ Many, many thanks to:
 * `Ryan Tenney <https://github.com/ryantenney>`_
 * `saadmufti <https://github.com/saadmufti>`_
 * `Sam Perman <https://github.com/samperman>`_
+* `Sammy Chu <https://github.com/sammyhk>`_
 * `Samy Dindane <https://github.com/Dinduks>`_
+* `Scott Leberknight <https://github.com/sleberknight>`_
 * `Sean Laurent <https://github.com/organicveggie>`_
 * `Sebastian LΓΆvdahl <https://github.com/slovdahl>`_
 * `Sergey Nazarov <https://github.com/phearnot>`_
+* `Sergio Escalante <https://github.com/sergioescala>`_
+* `Shashank babu <https://github.com/shashank-babu>`_
 * `Silvia MandalΓ  <https://github.com/simad>`_
 * `sofax <https://github.com/sofax>`_
+* `Stephen Souness <https://github.com/Sounie>`_
 * `Steve Fosdal <https://github.com/sfosdal>`_
 * `Steven Schlansker <https://github.com/stevenschlansker>`_
 * `stockmaj <https://github.com/stockmaj>`_
 * `Stuart Gunter <https://github.com/stuartgunter>`_
+* `Tamas Cservenak <https://github.com/cstamas>`_
 * `Thomas Cashman <https://github.com/tomcashman>`_
+* `Tim Van Laer <https://github.com/timvlaer>`_
 * `Tobias Bieniek <https://github.com/Turbo87>`_
 * `Tobias Lidskog <https://github.com/tobli>`_
 * `Tom Akehurst <https://github.com/tomakehurst>`_
+* `Tom Golden <https://github.com/TomRK1089>`_
+* `Tomas Celaya <https://github.com/tjcelaya>`_
 * `Tomasz Guzik <https://github.com/tguzik>`_
 * `Tomasz Nurkiewicz <https://github.com/nurkiewicz>`_
-* `Tom Golden <https://github.com/TomRK1089>`_
+* `tomayoola <https://github.com/tomayoola>`_
 * `tvleminckx <https://github.com/tvleminckx>`_
+* `Ufuk Celebi <https://github.com/uce>`_
 * `v-garki <https://github.com/v-garki>`_
+* `Vadym Pechenoha <https://github.com/pechenoha>`_
+* `Vasileios <https://github.com/vasilhsfoto>`_
 * `Vladimir Bukhtoyarov <https://github.com/vladimir-bukhtoyarov>`_
 * `Volker Fritzsch <https://github.com/volker>`_
 * `Wolfgang Hoschek <https://github.com/whoschek>`_
 * `Wolfgang Schell <https://github.com/jetztgradnet>`_
 * `yeyangever <https://github.com/yeyangever>`_
+* `Yuriy Badalyantc <https://github.com/LMnet>`_
 * `Zach A. Thomas <https://github.com/zathomas>`_
diff --git a/docs/source/about/release-notes.rst b/docs/source/about/release-notes.rst
index 7d96638..d6a5423 100644
--- a/docs/source/about/release-notes.rst
+++ b/docs/source/about/release-notes.rst
@@ -4,6 +4,56 @@
 Release Notes
 #############
 
+Please refer to the `GitHub releases <https://github.com/dropwizard/metrics/releases>`_ for the latest releases of Dropwizard Metrics. 
+
+.. _rel-4.0.0:
+
+v4.0.0: Dec 24 2017
+===================
+
+* Compiled and targeted JDK8
+* Support for running under JDK9 `#1236 <https://github.com/dropwizard/metrics/pull/1236>`_
+* Move JMX reporting to the ``metrics-jmx`` module
+* Add Bill of Materials for Metrics #1239 `#1239 <https://github.com/dropwizard/metrics/pull/1239>`_
+* Used Java 8 Time API for data formatting
+* Removed unnecessary reflection hacks for ``HealthCheckRegistry``
+* Removed internal ``LongAdder``
+* Removed internal ``ThreadLocalRandom``
+* Optimized generating random numbers
+* ``Timer.Context`` now implements ``AutoCloseable``
+* Upgrade Jetty integration to Jetty 9.4
+* Support tracking Jersey filters in Jersey resources `#1118 <https://github.com/dropwizard/metrics/pull/1239>`_
+* Add ``ResponseMetered`` annotation for Jersey resources `#1186 <https://github.com/dropwizard/metrics/pull/1186>`_
+* Add a method for timing non-throwing functions. `#1224 <https://github.com/dropwizard/metrics/pull/1224>`_
+* Unnecessary clear operation for ChunkedAssociativeArray `#1211 <https://github.com/dropwizard/metrics/pull/1211>`_
+* Add some common metric filters `#1210 <https://github.com/dropwizard/metrics/pull/1210>`_
+* Add possibility to subclass Timer.Context `#1226 <https://github.com/dropwizard/metrics/pull/1226>`_
+
+.. _rel-3.2.6:
+
+v3.2.6: Dec 24 2017
+===================
+
+* Jetty9: unhandled response should be counted as 404 and not 200 `#1232 <https://github.com/dropwizard/metrics/pull/1232>`_
+* Prevent NaN values when calculating mean `#1230 <https://github.com/dropwizard/metrics/pull/1230>`_
+* Avoid NaN values in WeightedSnapshot `#1233 <https://github.com/dropwizard/metrics/pull/1233>`_
+
+.. _rel-3.2.5:
+
+v3.2.5: Sep 15 2017
+===================
+
+* [InstrumentedScheduledExecutorService] Fix the scheduledFixedDelay to call the correct method `#1192 <https://github.com/dropwizard/metrics/pull/1192>`_
+
+.. _rel-3.2.4:
+
+v3.2.4: Aug 24 2017
+===================
+
+* Fix GraphiteReporter rate reporting `#1167 <https://github.com/dropwizard/metrics/pull/1167>`_
+* Remove non Jdk6 compatible letter from date pattern `#1163 <https://github.com/dropwizard/metrics/pull/1163>`_
+* Fix uncaught CancellationException when stopping reporter `#1170 <https://github.com/dropwizard/metrics/pull/1170>`_
+
 .. _rel-3.2.3:
 
 v3.2.3: Jun 28 2017
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 49c301b..9c5d462 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -41,7 +41,7 @@ master_doc = 'index'
 
 # General information about the project.
 project = u'Metrics'
-copyright = u'2010-2014, Coda Hale, Yammer Inc.'
+copyright = u'2010-2014, Coda Hale, Yammer Inc., 2014-2017 Dropwizard Team'
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
@@ -107,7 +107,7 @@ html_theme_options = {
     'landing_logo_width': u'200px',
     'github_page': u'https://github.com/dropwizard/metrics',
     'mailing_list': u'https://groups.google.com/forum/#!forum/metrics-user',
-    'apidocs': u'https://dropwizard.github.io/metrics/' + release + '/apidocs/'
+    'apidocs': u'https://www.javadoc.io/doc/io.dropwizard.metrics/metrics-core/' + release + '/'
 }
 
 # Add any paths that contain custom themes here, relative to this directory.
diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst
index 9576aaa..2c3247b 100644
--- a/docs/source/getting-started.rst
+++ b/docs/source/getting-started.rst
@@ -138,7 +138,7 @@ To run
 
 .. code-block:: sh
 
-  mvn package exec:java -Dexec.mainClass=sample.First
+  mvn package exec:java -Dexec.mainClass=sample.GetStarted
 
 
 .. _gs-registry:
@@ -264,13 +264,10 @@ duration.
     private final Timer responses = metrics.timer(name(RequestHandler.class, "responses"));
 
     public String handleRequest(Request request, Response response) {
-        final Timer.Context context = responses.time();
-        try {
+        try(final Timer.Context context = responses.time()) {
             // etc;
             return "OK";
-        } finally {
-            context.stop();
-        }
+        } // catch and final logic goes here
     }
 
 This timer will measure the amount of time it takes to process each request in nanoseconds and
@@ -343,7 +340,20 @@ built-in thread deadlock detection to determine if any threads are deadlocked.
 Reporting Via JMX
 =================
 
-To report metrics via JMX:
+To report metrics via JMX, include the ``metrics-jmx`` module as a dependency:
+
+.. code-block:: xml
+
+    <dependency>
+        <groupId>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-jmx</artifactId>
+        <version>${metrics.version}</version>
+    </dependency>
+
+.. note::
+
+    Make sure you have a ``metrics.version`` property declared in your POM with the current version,
+    which is |release|.
 
 .. code-block:: java
 
@@ -399,5 +409,4 @@ In addition to JMX and HTTP, Metrics also has reporters for the following output
 * ``STDOUT``, using :ref:`ConsoleReporter <man-core-reporters-console>` from ``metrics-core``
 * ``CSV`` files, using :ref:`CsvReporter <man-core-reporters-csv>` from ``metrics-core``
 * SLF4J loggers, using :ref:`Slf4jReporter <man-core-reporters-slf4j>` from ``metrics-core``
-* Ganglia, using :ref:`GangliaReporter <manual-ganglia>` from ``metrics-ganglia``
 * Graphite, using :ref:`GraphiteReporter <manual-graphite>` from ``metrics-graphite``
diff --git a/docs/source/index.rst b/docs/source/index.rst
index a253f71..3bf8d88 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -12,7 +12,7 @@ Metrics is a Java library which gives you unparalleled insight into what your co
 **in your production environment.**
 
 With modules for common libraries like **Jetty**, **Logback**, **Log4j**, **Apache HttpClient**,
-**Ehcache**, **JDBI**, **Jersey** and reporting backends like **Ganglia** and **Graphite**, Metrics
+**Ehcache**, **JDBI**, **Jersey** and reporting backends like **Graphite**, Metrics
 provides you with full-stack visibility.
 
 .. toctree::
diff --git a/docs/source/manual/caffeine.rst b/docs/source/manual/caffeine.rst
new file mode 100644
index 0000000..45f61d1
--- /dev/null
+++ b/docs/source/manual/caffeine.rst
@@ -0,0 +1,36 @@
+.. _manual-caffeine:
+
+#####################
+Instrumenting Caffeine
+#####################
+
+.. highlight:: text
+
+.. rubric:: The ``metrics-caffeine`` module provides ``MetricsStatsCounter``, a metrics listener for
+            Caffeine_ caches:
+
+.. _Caffeine: https://github.com/ben-manes/caffeine
+
+.. code-block:: java
+
+    LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
+        .recordStats(() -> new MetricsStatsCounter(registry, "cache"))
+        .build(key -> key);
+
+The listener publishes these metrics:
+
++---------------------------+----------------------------------------------------------------------+
+| ``hits``                  | Number of times a requested item was found in the cache.             |
++---------------------------+----------------------------------------------------------------------+
+| ``misses``                | Number of times a requested item was not found in the cache.         |
++---------------------------+----------------------------------------------------------------------+
+| ``loads-success``         | Timer for successful loads into cache.                               |
++---------------------------+----------------------------------------------------------------------+
+| ``loads-failure``         | Timer for failed loads into cache.                                   |
++---------------------------+----------------------------------------------------------------------+
+| ``evictions``             | Histogram of eviction weights      .                                 |
++---------------------------+----------------------------------------------------------------------+
+| ``evictions-weight``      | Total weight of evicted entries.                                     |
++---------------------------+----------------------------------------------------------------------+
+| ``evictions.<CAUSE>``     | Histogram of eviction weights for each RemovalCause                  |
++---------------------------+----------------------------------------------------------------------+
diff --git a/docs/source/manual/collectd.rst b/docs/source/manual/collectd.rst
new file mode 100644
index 0000000..7612f8f
--- /dev/null
+++ b/docs/source/manual/collectd.rst
@@ -0,0 +1,21 @@
+.. _manual-collectd:
+
+#####################
+Reporting to Collectd
+#####################
+
+The ``metrics-collectd`` module provides ``CollectdReporter``, which allows your application to
+constantly stream metric values to a Collectd_ server:
+
+.. _Collectd: https://collectd.org/
+
+.. code-block:: java
+
+    final Sender sender = new Sender("collectd.example.com", 2007);
+    final CollectdReporter reporter = CollectdReporter.forRegistry(registry)
+                                                      .convertRatesTo(TimeUnit.SECONDS)
+                                                      .convertDurationsTo(TimeUnit.MILLISECONDS)
+                                                      .filter(MetricFilter.ALL)
+                                                      .build(sender);
+    reporter.start(1, TimeUnit.MINUTES);
+
diff --git a/docs/source/manual/core.rst b/docs/source/manual/core.rst
index 4fb0f1b..8367cf9 100644
--- a/docs/source/manual/core.rst
+++ b/docs/source/manual/core.rst
@@ -181,7 +181,7 @@ returned by a search:
 
 .. code-block:: java
 
-    final Histogram resultCounts = registry.histogram(name(ProductDAO.class, "result-counts");
+    final Histogram resultCounts = registry.histogram(name(ProductDAO.class, "result-counts"));
     resultCounts.update(results.size());
 
 ``Histogram`` metrics allow you to measure not just easy things like the min, mean, max, and
@@ -206,7 +206,7 @@ Metrics provides a number of different ``Reservoir`` implementations, each of wh
 Uniform Reservoirs
 ------------------
 
-A histogram with a uniform reservoir produces quantiles which are valid for the entirely of the
+A histogram with a uniform reservoir produces quantiles which are valid for the entirety of the
 histogram's lifetime. It will return a median value, for example, which is the median of all the
 values the histogram has ever been updated with. It does this by using an algorithm called
 `Vitter's R`__), which randomly selects values for the reservoir with linearly-decreasing
@@ -428,8 +428,6 @@ Metrics has other reporter implementations, too:
 * :ref:`MetricsServlet <manual-servlets>` is a servlet which not only exposes your metrics as a JSON
   object, but it also runs your health checks, performs thread dumps, and exposes valuable JVM-level
   and OS-level information.
-* :ref:`GangliaReporter <manual-ganglia>` allows you to constantly stream metrics data to your
-  Ganglia servers.
 * :ref:`GraphiteReporter <manual-graphite>` allows you to constantly stream metrics data to your
   Graphite servers.
 
diff --git a/docs/source/manual/ganglia.rst b/docs/source/manual/ganglia.rst
deleted file mode 100644
index ff5ac8c..0000000
--- a/docs/source/manual/ganglia.rst
+++ /dev/null
@@ -1,19 +0,0 @@
-.. _manual-ganglia:
-
-####################
-Reporting to Ganglia
-####################
-
-The ``metrics-ganglia`` module provides ``GangliaReporter``, which allows your application to
-constantly stream metric values to a Ganglia_ server:
-
-.. _Ganglia: http://ganglia.sourceforge.net/
-
-.. code-block:: java
-
-    final GMetric ganglia = new GMetric("ganglia.example.com", 8649, UDPAddressingMode.MULTICAST, 1);
-    final GangliaReporter reporter = GangliaReporter.forRegistry(registry)
-                                                    .convertRatesTo(TimeUnit.SECONDS)
-                                                    .convertDurationsTo(TimeUnit.MILLISECONDS)
-                                                    .build(ganglia);
-    reporter.start(1, TimeUnit.MINUTES);
diff --git a/docs/source/manual/index.rst b/docs/source/manual/index.rst
index a094801..273f716 100644
--- a/docs/source/manual/index.rst
+++ b/docs/source/manual/index.rst
@@ -14,7 +14,8 @@ User Manual
     core
     healthchecks
     ehcache
-    ganglia
+    caffeine
+    collectd
     graphite
     httpclient
     jdbi
diff --git a/docs/source/manual/jdbi.rst b/docs/source/manual/jdbi.rst
index dd4a7da..8fa3074 100644
--- a/docs/source/manual/jdbi.rst
+++ b/docs/source/manual/jdbi.rst
@@ -4,7 +4,7 @@
 Instrumenting JDBI
 ##################
 
-The ``metrics-jdbi`` module provides a ``TimingCollector`` implementation for JDBI_, an SQL
+The ``metrics-jdbi`` and ``metrics-jdbi3`` modules provide a ``TimingCollector`` implementation for JDBI_, an SQL
 convenience library.
 
 .. _JDBI: http://jdbi.org/
diff --git a/docs/source/manual/jersey.rst b/docs/source/manual/jersey.rst
index f88a151..1e47a13 100644
--- a/docs/source/manual/jersey.rst
+++ b/docs/source/manual/jersey.rst
@@ -1,44 +1,5 @@
 .. _manual-jersey:
 
-########################
-Instrumenting Jersey 1.x
-########################
-
-The ``metrics-jersey`` module provides ``InstrumentedResourceMethodDispatchAdapter``, which allows
-you to instrument methods on your `Jersey 1.x`_ resource classes:
-
-.. _Jersey 1.x: https://jersey.java.net/documentation/1.18/index.html
-
-An instance of ``InstrumentedResourceMethodDispatchAdapter`` must be registered with your Jersey
-application's ``ResourceConfig`` as a singleton provider for this to work.
-
-.. code-block:: java
-
-   public class ExampleApplication {
-        private final DefaultResourceConfig config = new DefaultResourceConfig();
-
-        public void init() {
-            config.getSingletons().add(new InstrumentedResourceMethodDispatchAdapter(registry));
-            config.getClasses().add(ExampleResource.class);
-        }
-    }
-
-
-    @Path("/example")
-    @Produces(MediaType.TEXT_PLAIN)
-    public class ExampleResource {
-        @GET
-        @Timed
-        public String show() {
-            return "yay";
-        }
-    }
-
-The ``show`` method in the above example will have a timer attached to it, measuring the time spent
-in that method.
-
-Use of the ``@Metered`` and ``@ExceptionMetered`` annotations is also supported.
-
 ########################
 Instrumenting Jersey 2.x
 ########################
@@ -50,7 +11,7 @@ which allows you to instrument methods on your `Jersey 2.x`_ resource classes:
 The ``metrics-jersey2`` module provides ``InstrumentedResourceMethodApplicationListener``, which allows
 you to instrument methods on your `Jersey 2.x`_ resource classes:
 
-.. _Jersey 2.x: https://jersey.java.net/documentation/latest/index.html
+.. _Jersey 2.x: https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/index.html
 
 An instance of ``InstrumentedResourceMethodApplicationListener`` must be registered with your Jersey
 application's ``ResourceConfig`` as a singleton provider for this to work.
@@ -76,9 +37,43 @@ application's ``ResourceConfig`` as a singleton provider for this to work.
         public String show() {
             return "yay";
         }
+
+        @GET
+        @Metered(name = "fancyName")
+        @Path("/metered")
+        public String metered() {
+            return "woo";
+        }
+
+        @GET
+        @ExceptionMetered(cause = IOException.class)
+        @Path("/exception-metered")
+        public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
+            if (splode) {
+                throw new IOException("AUGH");
+            }
+            return "fuh";
+        }
+
+        @GET
+        @ResponseMetered(level = ResponseMeteredLevel.ALL)
+        @Path("/response-metered")
+        public Response responseMetered(@QueryParam("invalid") @DefaultValue("false") boolean invalid) {
+            if (invalid) {
+                return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+            }
+            return Response.ok().build();
+        }
     }
 
-The ``show`` method in the above example will have a timer attached to it, measuring the time spent
-in that method.
+Supported Annotations
+=====================
+
+Every resource method or the class itself can be annotated with @Timed, @Metered, @ResponseMetered and @ExceptionMetered.
+If the annotation is placed on the class, it will apply to all its resource methods.
 
-Use of the ``@Metered`` and ``@ExceptionMetered`` annotations is also supported.
+* ``@Timed`` adds a timer and measures time spent in that method.
+* ``@Metered`` adds a meter and measures the rate at which the resource method is accessed.
+* ``@ResponseMetered`` adds meters and measures rate for response codes based on the selected level.
+* ``@ExceptionMetered`` adds a meter and measures how often the specified exception occurs when processing the resource.
+  If the ``cause`` is not specified, the default is ``Exception.class``.
diff --git a/docs/source/manual/jetty.rst b/docs/source/manual/jetty.rst
index 3ecd423..b69b6ea 100644
--- a/docs/source/manual/jetty.rst
+++ b/docs/source/manual/jetty.rst
@@ -4,12 +4,11 @@
 Instrumenting Jetty
 ###################
 
-The ``metrics-jetty8`` (Jetty 8.0), ``metrics-jetty9-legacy`` (Jetty 9.0), and ``metrics-jetty9``
-(Jetty 9.1 and higher) modules provides a set of instrumented equivalents of Jetty_ classes:
+The ``metrics-jetty9`` (Jetty 9.3 and higher) modules provides a set of instrumented equivalents of Jetty_ classes:
 ``InstrumentedBlockingChannelConnector``, ``InstrumentedHandler``, ``InstrumentedQueuedThreadPool``,
 ``InstrumentedSelectChannelConnector``, and ``InstrumentedSocketConnector``.
 
-.. _Jetty: http://www.eclipse.org/jetty/
+.. _Jetty: https://www.eclipse.org/jetty/
 
 The ``Connector`` implementations are simple, instrumented subclasses of the Jetty connector types
 which measure connection duration, the rate of accepted connections, connections, disconnections,
diff --git a/docs/source/manual/json.rst b/docs/source/manual/json.rst
index a973c9a..83bd99f 100644
--- a/docs/source/manual/json.rst
+++ b/docs/source/manual/json.rst
@@ -6,7 +6,7 @@ JSON Support
 
 Metrics comes with ``metrics-json``, which features two reusable modules for Jackson_.
 
-.. _Jackson: http://wiki.fasterxml.com/JacksonHome
+.. _Jackson: https://github.com/FasterXML/jackson
 
 This allows for the serialization of all metric types and health checks to a standard,
 easily-parsable JSON format.
diff --git a/docs/source/manual/jvm.rst b/docs/source/manual/jvm.rst
index 03f7b18..c887447 100644
--- a/docs/source/manual/jvm.rst
+++ b/docs/source/manual/jvm.rst
@@ -13,4 +13,4 @@ Supported metrics include:
 * Memory usage for all memory pools, including off-heap memory
 * Breakdown of thread states, including deadlocks
 * File descriptor usage
-* Buffer pool sizes and utilization (Java 7 only)
+* Buffer pool sizes and utilization
diff --git a/docs/source/manual/log4j.rst b/docs/source/manual/log4j.rst
index 5c8863c..c64467d 100644
--- a/docs/source/manual/log4j.rst
+++ b/docs/source/manual/log4j.rst
@@ -4,23 +4,10 @@
 Instrumenting Log4j
 ###################
 
-The ``metrics-log4j`` and ``metrics-log4j2`` modules provide ``InstrumentedAppender``, a Log4j ``Appender`` implementation
-(for log4j 1.x and log4j 2.x correspondingly) which records the rate of logged events by their logging level.
-
-
-You can add it to the root logger programmatically.
-
-For log4j 1.x:
-
-.. code-block:: java
-
-    InstrumentedAppender appender = new InstrumentedAppender(registry);
-    appender.activateOptions();
-    LogManager.getRootLogger().addAppender(appender);
-
-
-For log4j 2.x:
+The ``metrics-log4j2`` module provide ``InstrumentedAppender``, a Log4j_ ``Appender`` implementation
+which records the rate of logged events by their logging level. You can add it to the root logger programmatically.
 
+.. _Log4j: https://logging.apache.org/log4j/
 .. code-block:: java
 
     Filter filter = null;        // That's fine if we don't use filters; https://logging.apache.org/log4j/2.x/manual/filters.html
diff --git a/docs/source/manual/logback.rst b/docs/source/manual/logback.rst
index cf7ed71..8ebefdb 100644
--- a/docs/source/manual/logback.rst
+++ b/docs/source/manual/logback.rst
@@ -4,9 +4,11 @@
 Instrumenting Logback
 #####################
 
-The ``metrics-logback`` module provides ``InstrumentedAppender``, a Logback ``Appender``
+The ``metrics-logback`` module provides ``InstrumentedAppender``, a Logback_ ``Appender``
 implementation which records the rate of logged events by their logging level.
 
+.. _Logback: https://logback.qos.ch/
+
 You add it to the root logger programmatically:
 
 .. code-block:: java
diff --git a/docs/source/manual/servlets.rst b/docs/source/manual/servlets.rst
index b7eea74..ca5c62f 100644
--- a/docs/source/manual/servlets.rst
+++ b/docs/source/manual/servlets.rst
@@ -11,15 +11,66 @@ The ``metrics-servlets`` module provides a handful of useful servlets:
 HealthCheckServlet
 ==================
 
-``HealthCheckServlet`` responds to ``GET`` requests by running all the [health checks](#health-checks)
-and returning ``501 Not Implemented`` if no health checks are registered, ``200 OK`` if all pass, or
-``500 Internal Service Error`` if one or more fail. The results are returned as a human-readable
-``text/plain`` entity.
+``HealthCheckServlet`` responds to ``GET`` requests by running all the currently-registered
+[health checks](#health-checks). The results are returned as a human-readable JSON entity.
+
+HTTP Status Codes
+-----------------
+
+``HealthCheckServlet`` responds with one of the following status codes (depending on configuration).
+If reporting health via HTTP status is disabled, callers will have to introspect the JSON to
+determine application health.
+
+* ``501 Not Implemented``: If no health checks are registered
+* ``200 OK``: If all checks pass, or if ``httpStatusIndicator`` is set to ``"false"`` and one or more
+  health checks fail (see below for more information on this setting)
+* ``500 Internal Service Error``: If ``httpStatusIndicator`` is set to ``"true"`` and one or more
+  health checks fail (see below for more information on this setting)
+
+Configuration
+-------------
+
+``HealthCheckServlet`` supports the following configuration items.
+
+Servlet Context
+~~~~~~~~~~~~~~~
 
 ``HealthCheckServlet`` requires that the servlet context has a ``HealthCheckRegistry`` named
 ``com.codahale.metrics.servlets.HealthCheckServlet.registry``. You can subclass
-``MetricsServletContextListener``, which will add a specific ``HealthCheckRegistry`` to the servlet
-context.
+``HealthCheckServlet.ContextListener``, which will add a specific ``HealthCheckRegistry`` to the
+servlet context.
+
+An instance of ``ExecutorService`` can be provided via the servlet context using the name
+``com.codahale.metrics.servlets.HealthCheckServlet.executor``; by default, no thread pool is used to
+execute the health checks.
+
+An instance of ``HealthCheckFilter`` can be provided via the servlet context using the name
+``com.codahale.metrics.servlets.HealthCheckServlet.healthCheckFilter``; by default, no filtering is
+enabled. The filter is used to determine which health checks to include in the health status.
+
+An instance of ``ObjectMapper`` can be provided via the servlet context using the name
+``com.codahale.metrics.servlets.HealthCheckServlet.mapper``; if none is provided, a default instance
+will be used to convert the health check results to JSON.
+
+Initialization Parameters
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``HealthCheckServlet`` supports the following initialization parameters:
+
+* ``com.codahale.metrics.servlets.HealthCheckServlet.httpStatusIndicator``: Provides the
+  default setting that determines whether the HTTP status code is used to determine whether the
+  application is healthy; if not provided, it defaults to ``"true"``
+
+Query Parameters
+~~~~~~~~~~~~~~~~
+
+``HealthCheckServlet`` supports the following query parameters:
+
+* ``httpStatusIndicator`` (``Boolean``): Determines whether the HTTP status code is used to
+  determine whether the application is healthy; if not provided, it defaults to the value from the
+  initialization parameter
+* ``pretty`` (``Boolean``): Indicates whether the JSON response should be formatted; if
+  ``"true"``, the JSON response will be formatted instead of condensed
 
 .. _man-servlet-threaddump:
 
@@ -30,6 +81,21 @@ ThreadDumpServlet
 threads in the JVM, their states, their stack traces, and the state of any locks they may be
 waiting for.
 
+Configuration
+-------------
+
+``ThreadDumpServlet`` supports the following configuration items.
+
+Query Parameters
+~~~~~~~~~~~~~~~~
+
+``ThreadDumpServlet`` supports the following query parameters:
+
+* ``monitors`` (``Boolean``): Determines whether locked monitors are included; if not provided,
+  it defaults to ``"true"``
+* ``synchronizers`` (``Boolean``): Determines whether locked ownable synchronizers are included;
+  if not provided, it defaults to ``"true"``
+
 .. _man-servlet-metrics:
 
 MetricsServlet
@@ -37,13 +103,50 @@ MetricsServlet
 
 ``MetricsServlet`` exposes the state of the metrics in a particular registry as a JSON object.
 
+Configuration
+-------------
+
+``MetricsServlet`` supports the following configuration items.
+
+Servlet Context
+~~~~~~~~~~~~~~~
+
 ``MetricsServlet`` requires that the servlet context has a ``MetricRegistry`` named
 ``com.codahale.metrics.servlets.MetricsServlet.registry``. You can subclass
-``MetricsServletContextListener``, which will add a specific ``MetricRegistry`` to the servlet
+``MetricsServlet.ContextListener``, which will add a specific ``MetricRegistry`` to the servlet
 context.
 
-``MetricsServlet`` also takes an initialization parameter, ``show-jvm-metrics``, which if ``"false"`` will
-disable the outputting of JVM-level information in the JSON object.
+An instance of ``MetricFilter`` can be provided via the servlet context using the name
+``com.codahale.metrics.servlets.MetricsServlet.metricFilter``; by default, no filtering is
+enabled. The filter is used to determine which metrics to include in the JSON output.
+
+Initialization Parameters
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``MetricsServlet`` supports the following initialization parameters:
+
+* ``com.codahale.metrics.servlets.MetricsServlet.allowedOrigin``: Provides a value for the
+  response header ``Access-Control-Allow-Origin``; if no value is provided, the header is not used
+* ``com.codahale.metrics.servlets.MetricsServlet.jsonpCalblack``: Specifies a request parameter
+  name to use as the callback when returning the metrics as JSON-P; if no value is provided, the response is
+  returned as JSON. This also requires a query parameter with the same name as the value to enable a JSON-P
+  response.
+* ``com.codahale.metrics.servlets.MetricsServlet.rateUnit``: Provides a value for the
+  rate unit used for metrics output; if none is provided, the default is ``SECONDS`` (see ``TimeUnit`` for
+  acceptable values)
+* ``com.codahale.metrics.servlets.MetricsServlet.durationUnit``: Provides a value for the
+  duration unit used for metrics output; if none is provided, the default is ``SECONDS`` (see ``TimeUnit`` for
+  acceptable values)
+* ``com.codahale.metrics.servlets.MetricsServlet.showSamples``: Controls whether sample data is
+  included in the output for histograms and timers; if no value is provided, the sample data will be omitted.
+
+Query Parameters
+~~~~~~~~~~~~~~~~
+
+``MetricsServlet`` supports the following query parameters:
+
+* ``pretty`` (``Boolean``): Determines whether the results are formatted; if not provided, this
+  parameter defaults to ``"false"``.
 
 .. _man-servlet-ping:
 
@@ -53,6 +156,32 @@ PingServlet
 ``PingServlet`` responds to ``GET`` requests with a ``text/plain``/``200 OK`` response of ``pong``. This is
 useful for determining liveness for load balancers, etc.
 
+.. _man-servlet-cpu-profile:
+
+CpuProfileServlet
+=================
+
+``CpuProfileServlet`` responds to ``GET`` requests with a ``pprof/raw``/``200 OK`` response containing the
+results of CPU profiling.
+
+Configuration
+-------------
+
+``CpuProfileServlet`` supports the following configuration items.
+
+Query Parameters
+~~~~~~~~~~~~~~~~
+
+``CpuProfileServlet`` supports the following query parameters:
+
+* ``duration`` (``Integer``): Determines the amount of time in seconds for which the CPU
+  profiling will occur; the default is 10 seconds.
+* ``frequency`` (``Integer``)Determines the frequency in Hz at which the CPU
+  profiling sample; the default is 100 Hz (100 times per second).
+* ``state`` (``String``): Determines which threads will be profiled. If the value provided
+  is ``"blocked"``, only blocked threads will be profiled; otherwise, all runnable threads will be
+  profiled.
+
 .. _man-servlet-admin:
 
 AdminServlet
@@ -63,10 +192,13 @@ AdminServlet
 
 * ``/``: an HTML admin menu with links to the following:
 
-  * ``/healthcheck``: ``HealthCheckServlet``
   * ``/metrics``: ``MetricsServlet``
+    * To change the URI, set the
   * ``/ping``: ``PingServlet``
   * ``/threads``: ``ThreadDumpServlet``
+  * ``/healthcheck``: ``HealthCheckServlet``
+  * ``/pprof``: ``CpuProfileServlet``
+    * There will be two links; one for the base profile and one for CPU contention
 
 You will need to add your ``MetricRegistry`` and ``HealthCheckRegistry`` instances to the servlet
 context as attributes named ``com.codahale.metrics.servlets.MetricsServlet.registry`` and
@@ -101,7 +233,8 @@ And by extending ``HealthCheckServlet.ContextListener`` for HealthCheckRegistry:
 
     }
 
-Then you will need to register servlet context listeners either in you ``web.xml`` or annotating the class with ``@WebListener`` if you are in servlet 3.0 environment. In ``web.xml``:
+Then you will need to register servlet context listeners either in you ``web.xml`` or annotating the class
+with ``@WebListener`` if you are in servlet 3.0 environment. In ``web.xml``:
 
 .. code-block:: xml
 
@@ -125,4 +258,29 @@ You will also need to register ``AdminServlet`` in ``web.xml``:
 		<url-pattern>/metrics/*</url-pattern>
 	</servlet-mapping>
 
-
+Configuration
+-------------
+
+``AdminServlet`` supports the following configuration items.
+
+Initialization Parameters
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``AdminServlet`` supports the following initialization parameters:
+
+* ``metrics-enabled``: Determines whether the ``MetricsServlet`` is enabled and
+  routable; if ``"false"``, the servlet endpoint will not be available via this servlet
+* ``metrics-uri``: Specifies the URI for the ``MetricsServlet``; if omitted, the default
+  (``/metrics``) will be used
+* ``ping-enabled``: Determines whether the ``PingServlet`` is enabled and routable; if
+  ``"false"``, the servlet endpoint will not be available via this servlet
+* ``ping-uri``: Specifies the URI for the ``PingServlet``; if omitted, the default
+  (``/ping``) will be used
+* ``threads-enabled``: Determines whether the ``ThreadDumpServlet`` is enabled
+  and routable; if ``"false"``, the servlet endpoint will not be available via this servlet
+* ``threads-uri``: Specifies the URI for the ``ThreadDumpServlet``; if omitted, the default
+  (``/threads``) will be used
+* ``cpu-profile-enabled``: Determines whether the ``CpuProfileServlet`` is enabled and routable;
+  if ``"false"``, the servlet endpoints will not be available via this servlet
+* ``cpu-profile-uri``: Specifies the URIs for the ``CpuProfileServlet``; if omitted, the default
+  (``/pprof``) will be used
diff --git a/docs/source/manual/third-party.rst b/docs/source/manual/third-party.rst
index 0511ed1..46a8b1e 100644
--- a/docs/source/manual/third-party.rst
+++ b/docs/source/manual/third-party.rst
@@ -12,13 +12,14 @@ Instrumented Libraries
 
 * `camel-metrics <https://github.com/InitiumIo/camel-metrics>`_ provides component for your `Apache Camel <https://camel.apache.org/>`_ route.
 * `hdrhistogram-metrics-reservoir <https://bitbucket.org/marshallpierce/hdrhistogram-metrics-reservoir>`_ provides a Histogram reservoir backed by `HdrHistogram <http://hdrhistogram.org/>`_.
-* `jersey2-metrics <https://bitbucket.org/marshallpierce/jersey2-metrics>`_ provides integration with `Jersey 2 <https://jersey.java.net/>`_.
+* `jersey2-metrics <https://bitbucket.org/marshallpierce/jersey2-metrics>`_ provides integration with `Jersey 2 <https://eclipse-ee4j.github.io/jersey/>`_.
 * `jersey-metrics-filter <https://github.com/palominolabs/jersey-metrics-filter>`_ provides integration with Jersey 1.
 * `metrics-aspectj <https://github.com/astefanutti/metrics-aspectj>`_ provides integration with `AspectJ <http://eclipse.org/aspectj/>`_.
 * `metrics-cdi <https://github.com/astefanutti/metrics-cdi>`_ provides integration with `CDI <http://www.cdi-spec.org/>`_ environments,
-* `metrics-guice <https://github.com/palominolabs/metrics-guice>`_ provides integration with `Guice <https://code.google.com/p/google-guice/>`_.
+* `metrics-guice <https://github.com/palominolabs/metrics-guice>`_ provides integration with `Guice <https://github.com/google/guice>`_.
 * `metrics-guice-servlet <https://github.com/palominolabs/metrics-guice-servlet>`_ provides `Guice Servlet <https://github.com/google/guice/wiki/Servlets>`_ integration with AdminServlet.
 * `metrics-okhttp <https://github.com/raskasa/metrics-okhttp>`_ provides integration with `OkHttp <http://square.github.io/okhttp>`_.
+* `metrics-feign <https://github.com/mwiede/metrics-feign>`_ provides integration with `Feign <https://github.com/OpenFeign/feign>`_.
 * `metrics-play <https://github.com/kenshoo/metrics-play>`_ provides an integration with the `Play Framework <https://www.playframework.com/>`_.
 * `metrics-spring <https://github.com/ryantenney/metrics-spring>`_ provides integration with `Spring <http://spring.io/>`_.
 * `wicket-metrics <https://github.com/NitorCreations/wicket-metrics>`_ provides easy integration for your `Wicket <http://wicket.apache.org/>`_ application.
@@ -38,15 +39,17 @@ Reporters
 * `metrics-cassandra <https://github.com/brndnmtthws/metrics-cassandra>`_ provides a reporter for `Apache Cassandra <https://cassandra.apache.org/>`_.
 * `metrics-circonus <https://github.com/circonus-labs/metrics-circonus>`_ provides a registry and reporter for sending metrics (including full histograms) to `Circonus <https://www.circonus.com/>`_.
 * `metrics-datadog <https://github.com/coursera/metrics-datadog>`_ provides a reporter to send data to `Datadog <http://www.datadoghq.com/>`_.
-* `metrics-elasticsearch-reporter <https://github.com/elasticsearch/elasticsearch-metrics-reporter-java>`_ provides a reporter for `elasticsearch <http://www.elasticsearch.org/>`_
+* `metrics-elasticsearch-reporter <https://github.com/elasticsearch/elasticsearch-metrics-reporter-java>`_ provides a reporter for `elasticsearch <https://www.elastic.co/>`_
 * `metrics-hadoop-metrics2-reporter <https://github.com/joshelser/dropwizard-hadoop-metrics2>`_ provides a reporter for `Hadoop Metrics2 <https://hadoop.apache.org/docs/r2.7.2/api/org/apache/hadoop/metrics2/package-summary.html>`_.
 * `metrics-hawkular <https://github.com/hawkular/hawkular-dropwizard-reporter>`_ provides a reporter for `Hawkular Metrics <http://www.hawkular.org/>`_.
-* `metrics-influxdb <https://github.com/novaquark/metrics-influxdb>`_ provides a reporter which announces measurements to `InfluxDB <http://influxdb.org/>`_
+* `metrics-influxdb <https://github.com/iZettle/dropwizard-metrics-influxdb>`_ provides a reporter for `InfluxDB <https://www.influxdata.com/>`_ with the Dropwizard framework integration.
+* `metrics-influxdb <https://github.com/kickstarter/dropwizard-influxdb-reporter>`_ provides a reporter for `InfluxDB <https://www.influxdata.com/>`_ 1.2+._
 * `metrics-instrumental <https://github.com/egineering-llc/metrics-instrumental>`_ provides a reporter to send data to `Instrumental <http://instrumentalapp.com/>`_.
 * `metrics-kafka <https://github.com/hengyunabc/metrics-kafka>`_ provides a reporter for `Kafka <http://kafka.apache.org/>`_.
 * `metrics-librato <https://github.com/librato/metrics-librato>`_ provides a reporter for `Librato Metrics <https://metrics.librato.com/>`_, a scalable metric collection, aggregation, monitoring, and alerting service.
 * `metrics-mongodb-reporter <https://github.com/aparnachaudhary/mongodb-metrics-reporter>`_ provides a reporter for `MongoDB <https://www.mongodb.org/>`_.
 * `metrics-munin-reporter <https://github.com/slashidea/metrics-munin-reporter>`_ provides a reporter for `Munin <http://munin-monitoring.org/>`_
+* `dropwizard-metrics-newrelic <https://github.com/newrelic/dropwizard-metrics-newrelic>`_ Officially supported (by New Relic) exporter which sends data to `New Relic <http://newrelic.com/>`_ as dimensional metrics.
 * `metrics-new-relic <https://github.com/palominolabs/metrics-new-relic>`_ provides a reporter which sends data to `New Relic <http://newrelic.com/>`_.
 * `metrics-reporter-config <https://github.com/addthis/metrics-reporter-config>`_ DropWizard-esque YAML configuration of reporters.
 * `metrics-signalfx <https://github.com/signalfx/signalfx-java>`_ provides a reporter to send data to `SignalFx <http://www.signalfx.com/>`_.
@@ -54,8 +57,9 @@ Reporters
 * `metrics-splunk <https://github.com/zenmoto/metrics-splunk>`_ provides a reporter for `Splunk <http://www.splunk.com/>`_.
 * `metrics-statsd <https://github.com/ReadyTalk/metrics-statsd>`_ provides a Metrics 2.x and 3.x reporter for `StatsD <https://github.com/etsy/statsd/>`_
 * `metrics-zabbiz <https://github.com/hengyunabc/metrics-zabbix>`_ provides a reporter for `Zabbix <http://www.zabbix.com/>`_.
-* `sematext-metrics-reporter <https://github.com/sematext/sematext-metrics-reporter>`_ provides a reporter for `SPM <http://sematext.com/spm/index.html>`_.
+* `sematext-metrics-reporter <https://github.com/sematext/sematext-metrics-reporter>`_ provides a reporter for `SPM <https://sematext.com/spm/>`_.
+* `metrics-jfr <https://github.com/poiu-de/metrics-jfr>`_ provides a reporter to publish event via `Java Flight Recorder <https://docs.oracle.com/en/java/java-components/jdk-mission-control/8/user-guide/using-jdk-flight-recorder.html>`_.
 
-Advansed metrics implementations
+Advanced metrics implementations
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 * `rolling-metrics <https://github.com/vladimir-bukhtoyarov/rolling-metrics>`_ provides a collection of advanced metrics with rolling time window semantic, such as Rolling-Counter, Hit-Ratio, Top and Reservoir backed by HdrHistogram.
diff --git a/metrics-annotation/pom.xml b/metrics-annotation/pom.xml
index 8de1f0f..8b07a78 100644
--- a/metrics-annotation/pom.xml
+++ b/metrics-annotation/pom.xml
@@ -5,9 +5,13 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.annotation</javaModuleName>
+    </properties>
+
     <artifactId>metrics-annotation</artifactId>
     <name>Annotations for Metrics</name>
     <packaging>bundle</packaging>
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/CachedGauge.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/CachedGauge.java
index 42b0827..9c0cd51 100644
--- a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/CachedGauge.java
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/CachedGauge.java
@@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit;
 /**
  * An annotation for marking a method as a gauge, which caches the result for a specified time.
  *
- * <p/>
+ * <p>
  * Given a method like this:
  * <pre><code>
  *     {@literal @}CachedGauge(name = "queueSize", timeout = 30, timeoutUnit = TimeUnit.SECONDS)
@@ -18,7 +18,7 @@ import java.util.concurrent.TimeUnit;
  *     }
  *
  * </code></pre>
- * <p/>
+ * <p>
  *
  * A gauge for the defining class with the name queueSize will be created which uses the annotated method's
  * return value as its value, and which caches the result for 30 seconds.
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Counted.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Counted.java
index 01c766e..3808395 100644
--- a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Counted.java
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Counted.java
@@ -10,7 +10,7 @@ import java.lang.annotation.Target;
 /**
  * An annotation for marking a method of an annotated object as counted.
  *
- * <p/>
+ * <p>
  * Given a method like this:
  * <pre><code>
  *     {@literal @}Counted(name = "fancyName")
@@ -18,7 +18,7 @@ import java.lang.annotation.Target;
  *         return "Sir Captain " + name;
  *     }
  * </code></pre>
- * <p/>
+ * <p>
  * A counter for the defining class with the name {@code fancyName} will be created and each time the
  * {@code #fancyName(String)} method is invoked, the counter will be marked.
  *
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ExceptionMetered.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ExceptionMetered.java
index 71c489a..ddc69fc 100644
--- a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ExceptionMetered.java
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ExceptionMetered.java
@@ -9,7 +9,7 @@ import java.lang.annotation.Target;
 
 /**
  * An annotation for marking a method of an annotated object as metered.
- * <p/>
+ * <p>
  * Given a method like this:
  * <pre><code>
  *     {@literal @}ExceptionMetered(name = "fancyName", cause=IllegalArgumentException.class)
@@ -17,14 +17,14 @@ import java.lang.annotation.Target;
  *         return "Sir Captain " + name;
  *     }
  * </code></pre>
- * <p/>
+ * <p>
  * A meter for the defining class with the name {@code fancyName} will be created and each time the
  * {@code #fancyName(String)} throws an exception of type {@code cause} (or a subclass), the meter
  * will be marked.
- * <p/>
+ * <p>
  * A name for the metric can be specified as an annotation parameter, otherwise, the metric will be
  * named based on the method name.
- * <p/>
+ * <p>
  * For instance, given a declaration of
  * <pre><code>
  *     {@literal @}ExceptionMetered
@@ -32,7 +32,7 @@ import java.lang.annotation.Target;
  *         return "Sir Captain " + name;
  *     }
  * </code></pre>
- * <p/>
+ * <p>
  * A meter named {@code fancyName.exceptions} will be created and marked every time an exception is
  * thrown.
  */
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Gauge.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Gauge.java
index 2849704..35819b4 100644
--- a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Gauge.java
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Gauge.java
@@ -7,7 +7,7 @@ import java.lang.annotation.Target;
 
 /**
  * An annotation for marking a method of an annotated object as a gauge.
- * <p/>
+ * <p>
  * Given a method like this:
  * <pre><code>
  *     {@literal @}Gauge(name = "queueSize")
@@ -15,7 +15,7 @@ import java.lang.annotation.Target;
  *         return queue.size;
  *     }
  * </code></pre>
- * <p/>
+ * <p>
  * A gauge for the defining class with the name {@code queueSize} will be created which uses the
  * annotated method's return value as its value.
  */
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metered.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metered.java
index d074020..f8b5db0 100644
--- a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metered.java
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metered.java
@@ -9,7 +9,7 @@ import java.lang.annotation.Target;
 
 /**
  * An annotation for marking a method of an annotated object as metered.
- * <p/>
+ * <p>
  * Given a method like this:
  * <pre><code>
  *     {@literal @}Metered(name = "fancyName")
@@ -17,7 +17,7 @@ import java.lang.annotation.Target;
  *         return "Sir Captain " + name;
  *     }
  * </code></pre>
- * <p/>
+ * <p>
  * A meter for the defining class with the name {@code fancyName} will be created and each time the
  * {@code #fancyName(String)} method is invoked, the meter will be marked.
  */
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metric.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metric.java
index 374b72f..ca7eaff 100755
--- a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metric.java
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Metric.java
@@ -1,18 +1,3 @@
-/**
- * Copyright (C) 2012 Ryan W Tenney (ryan@10e.us)
- *
- * Licensed 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 com.codahale.metrics.annotation;
 
 import java.lang.annotation.ElementType;
@@ -23,13 +8,13 @@ import java.lang.annotation.Target;
 /**
  * An annotation requesting that a metric be injected or registered.
  *
- * <p/>
+ * <p>
  * Given a field like this:
  * <pre><code>
  *     {@literal @}Metric
  *     public Histogram histogram;
  * </code></pre>
- * <p/>
+ * <p>
  * A meter of the field's type will be created and injected into managed objects.
  * It will be up to the user to interact with the metric. This annotation
  * can be used on fields of type Meter, Timer, Counter, and Histogram.
@@ -41,7 +26,7 @@ import java.lang.annotation.Target;
  *     {@literal @}Metric
  *     public Histogram uniformHistogram = new Histogram(new UniformReservoir());
  * </code></pre>
- * </p>
+ * <p>
  *
  * @since 3.1
  */
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ResponseMetered.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ResponseMetered.java
new file mode 100644
index 0000000..ea5d876
--- /dev/null
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ResponseMetered.java
@@ -0,0 +1,45 @@
+package com.codahale.metrics.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An annotation for marking a method of an annotated object as metered.
+ * <p>
+ * Given a method like this:
+ * <pre><code>
+ *     {@literal @}ResponseMetered(name = "fancyName", level = ResponseMeteredLevel.ALL)
+ *     public String fancyName(String name) {
+ *         return "Sir Captain " + name;
+ *     }
+ * </code></pre>
+ * <p>
+ * Meters for the defining class with the name {@code fancyName} will be created for response codes
+ * based on the ResponseMeteredLevel selected. Each time the {@code #fancyName(String)} method is invoked,
+ * the appropriate response meter will be marked.
+ */
+@Inherited
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.TYPE, ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
+public @interface ResponseMetered {
+    /**
+     * @return The name of the meter.
+     */
+    String name() default "";
+
+    /**
+     * @return If {@code true}, use the given name as an absolute name. If {@code false}, use the given name
+     * relative to the annotated class. When annotating a class, this must be {@code false}.
+     */
+    boolean absolute() default false;
+
+    /**
+     * @return the ResponseMeteredLevel which decides which response code meters are marked.
+     */
+    ResponseMeteredLevel level() default ResponseMeteredLevel.COARSE;
+}
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ResponseMeteredLevel.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ResponseMeteredLevel.java
new file mode 100644
index 0000000..d17d0e5
--- /dev/null
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/ResponseMeteredLevel.java
@@ -0,0 +1,23 @@
+package com.codahale.metrics.annotation;
+
+/**
+ * {@link ResponseMeteredLevel} is a parameter for the {@link ResponseMetered} annotation.
+ * The constants of this enumerated type decide what meters are included when a class
+ * or method is annotated with the {@link ResponseMetered} annotation.
+ */
+public enum ResponseMeteredLevel {
+    /**
+     * Include meters for 1xx/2xx/3xx/4xx/5xx responses
+     */
+    COARSE,
+
+    /**
+     * Include meters for every response code (200, 201, 303, 304, 401, 404, 501, etc.)
+     */
+    DETAILED,
+
+    /**
+     * Include meters for every response code in addition to top level 1xx/2xx/3xx/4xx/5xx responses
+     */
+    ALL;
+}
diff --git a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Timed.java b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Timed.java
index 10b56ee..bf8cd47 100644
--- a/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Timed.java
+++ b/metrics-annotation/src/main/java/com/codahale/metrics/annotation/Timed.java
@@ -9,7 +9,7 @@ import java.lang.annotation.Target;
 
 /**
  * An annotation for marking a method of an annotated object as timed.
- * <p/>
+ * <p>
  * Given a method like this:
  * <pre><code>
  *     {@literal @}Timed(name = "fancyName")
@@ -17,7 +17,7 @@ import java.lang.annotation.Target;
  *         return "Sir Captain " + name;
  *     }
  * </code></pre>
- * <p/>
+ * <p>
  * A timer for the defining class with the name {@code fancyName} will be created and each time the
  * {@code #fancyName(String)} method is invoked, the method's execution will be timed.
  */
diff --git a/metrics-benchmarks/findbugs-exclude.xml b/metrics-benchmarks/findbugs-exclude.xml
deleted file mode 100644
index 44f6116..0000000
--- a/metrics-benchmarks/findbugs-exclude.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-<FindBugsFilter>
-    <Match>
-        <Package name="com.codahale.metrics.benchmarks.generated" />
-    </Match>
-    <Match>
-        <Package name="org.openjdk.jmh.infra.generated" />
-    </Match>
-</FindBugsFilter>
diff --git a/metrics-benchmarks/pom.xml b/metrics-benchmarks/pom.xml
index 12c86e9..332801d 100644
--- a/metrics-benchmarks/pom.xml
+++ b/metrics-benchmarks/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-benchmarks</artifactId>
@@ -14,41 +14,58 @@
         A development module for performance benchmarks of Metrics classes.
     </description>
 
+    <properties>
+        <jmh.version>1.37</jmh.version>
+        <javaModuleName>com.codahale.metrics.benchmarks</javaModuleName>
+        <jar.skipIfEmpty>true</jar.skipIfEmpty>
+        <maven.install.skip>true</maven.install.skip>
+        <maven.deploy.skip>true</maven.deploy.skip>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>org.openjdk.jmh</groupId>
             <artifactId>jmh-core</artifactId>
-            <version>1.0.1</version>
-        </dependency>
-        <dependency>
-            <groupId>org.openjdk.jmh</groupId>
-            <artifactId>jmh-generator-annprocess</artifactId>
-            <version>1.0.1</version>
-            <scope>provided</scope>
+            <version>${jmh.version}</version>
         </dependency>
     </dependencies>
 
     <build>
         <plugins>
             <plugin>
-                <!-- don't deploy this -->
                 <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-deploy-plugin</artifactId>
-                <version>2.7</version>
+                <artifactId>maven-compiler-plugin</artifactId>
                 <configuration>
-                    <skip>true</skip>
+                    <annotationProcessorPaths combine.children="append">
+                        <path>
+                            <groupId>org.openjdk.jmh</groupId>
+                            <artifactId>jmh-generator-annprocess</artifactId>
+                            <version>${jmh.version}</version>
+                        </path>
+                    </annotationProcessorPaths>
                 </configuration>
             </plugin>
             <plugin>
                 <!-- generate an uber .jar for standalone benchmarking -->
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-shade-plugin</artifactId>
-                <version>2.2</version>
+                <version>3.5.1</version>
                 <executions>
                     <execution>
                         <phase>package</phase>
@@ -66,14 +83,6 @@
                     </execution>
                 </executions>
             </plugin>
-            <plugin>
-                <!-- exclude jmh generated classes designed to trick jvm like "unused fields" padding -->
-                <groupId>org.codehaus.mojo</groupId>
-                <artifactId>findbugs-maven-plugin</artifactId>
-                <configuration>
-                    <excludeFilterFile>findbugs-exclude.xml</excludeFilterFile> 
-                </configuration>
-            </plugin>
         </plugins>
     </build>
 </project>
diff --git a/metrics-benchmarks/src/main/java/com/codahale/metrics/benchmarks/CachedGaugeBenchmark.java b/metrics-benchmarks/src/main/java/com/codahale/metrics/benchmarks/CachedGaugeBenchmark.java
new file mode 100644
index 0000000..ae1d5cd
--- /dev/null
+++ b/metrics-benchmarks/src/main/java/com/codahale/metrics/benchmarks/CachedGaugeBenchmark.java
@@ -0,0 +1,46 @@
+package com.codahale.metrics.benchmarks;
+
+import com.codahale.metrics.CachedGauge;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.RunnerException;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+import java.util.concurrent.TimeUnit;
+
+@State(Scope.Benchmark)
+public class CachedGaugeBenchmark {
+
+    private CachedGauge<Integer> cachedGauge = new CachedGauge<Integer>(100, TimeUnit.MILLISECONDS) {
+        @Override
+        protected Integer loadValue() {
+            try {
+                Thread.sleep(10);
+            } catch (InterruptedException e) {
+                throw new RuntimeException("Thread was interrupted", e);
+            }
+            return 12345;
+        }
+    };
+
+    @Benchmark
+    public void perfGetValue(Blackhole blackhole) {
+        blackhole.consume(cachedGauge.getValue());
+    }
+
+    public static void main(String[] args) throws RunnerException {
+        Options opt = new OptionsBuilder()
+                .include(".*" + CachedGaugeBenchmark.class.getSimpleName() + ".*")
+                .warmupIterations(3)
+                .measurementIterations(5)
+                .threads(4)
+                .forks(1)
+                .build();
+
+        new Runner(opt).run();
+    }
+}
diff --git a/metrics-benchmarks/src/main/java/com/codahale/metrics/benchmarks/ReservoirBenchmark.java b/metrics-benchmarks/src/main/java/com/codahale/metrics/benchmarks/ReservoirBenchmark.java
index 5d2e788..b7c6801 100644
--- a/metrics-benchmarks/src/main/java/com/codahale/metrics/benchmarks/ReservoirBenchmark.java
+++ b/metrics-benchmarks/src/main/java/com/codahale/metrics/benchmarks/ReservoirBenchmark.java
@@ -1,6 +1,8 @@
 package com.codahale.metrics.benchmarks;
 
 import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.LockFreeExponentiallyDecayingReservoir;
+import com.codahale.metrics.Reservoir;
 import com.codahale.metrics.SlidingTimeWindowArrayReservoir;
 import com.codahale.metrics.SlidingTimeWindowReservoir;
 import com.codahale.metrics.SlidingWindowReservoir;
@@ -23,6 +25,7 @@ public class ReservoirBenchmark {
 
     private final UniformReservoir uniform = new UniformReservoir();
     private final ExponentiallyDecayingReservoir exponential = new ExponentiallyDecayingReservoir();
+    private final Reservoir lockFreeExponential = LockFreeExponentiallyDecayingReservoir.builder().build();
     private final SlidingWindowReservoir sliding = new SlidingWindowReservoir(1000);
     private final SlidingTimeWindowReservoir slidingTime = new SlidingTimeWindowReservoir(200, TimeUnit.MILLISECONDS);
     private final SlidingTimeWindowArrayReservoir arrTime = new SlidingTimeWindowArrayReservoir(200, TimeUnit.MILLISECONDS);
@@ -60,6 +63,12 @@ public class ReservoirBenchmark {
         return slidingTime;
     }
 
+    @Benchmark
+    public Object perfLockFreeExponentiallyDecayingReservoir() {
+        lockFreeExponential.update(nextValue);
+        return lockFreeExponential;
+    }
+
     public static void main(String[] args) throws RunnerException {
         Options opt = new OptionsBuilder()
             .include(".*" + ReservoirBenchmark.class.getSimpleName() + ".*")
diff --git a/metrics-bom/pom.xml b/metrics-bom/pom.xml
new file mode 100644
index 0000000..0ab914d
--- /dev/null
+++ b/metrics-bom/pom.xml
@@ -0,0 +1,191 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-bom</artifactId>
+    <name>Metrics BOM</name>
+    <packaging>pom</packaging>
+    <description>Bill of Materials for Metrics</description>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-annotation</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-caffeine</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-caffeine3</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-core</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-collectd</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-ehcache</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-graphite</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-healthchecks</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-httpclient</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-httpclient5</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-httpasyncclient</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jakarta-servlet</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jakarta-servlet6</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jakarta-servlets</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jcache</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jdbi</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jdbi3</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jersey2</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jersey3</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jersey31</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jetty9</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jetty10</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jetty11</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jetty12</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jetty12-ee10</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jmx</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-json</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-jvm</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-log4j2</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-logback</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-logback13</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-logback14</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-servlet</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-servlets</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+
+        </dependencies>
+    </dependencyManagement>
+</project>
diff --git a/metrics-caffeine/pom.xml b/metrics-caffeine/pom.xml
new file mode 100644
index 0000000..a719829
--- /dev/null
+++ b/metrics-caffeine/pom.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-caffeine</artifactId>
+    <name>Metrics Integration for Caffeine 2.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        Metrics Integration for Caffeine 2.x.
+    </description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.caffeine</javaModuleName>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>com.github.ben-manes.caffeine</groupId>
+                <artifactId>caffeine</artifactId>
+                <version>2.9.3</version>
+            </dependency>
+            <dependency>
+                <groupId>org.checkerframework</groupId>
+                <artifactId>checker-qual</artifactId>
+                <version>3.42.0</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.checkerframework</groupId>
+            <artifactId>checker-qual</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-caffeine/src/main/java/com/codahale/metrics/caffeine/MetricsStatsCounter.java b/metrics-caffeine/src/main/java/com/codahale/metrics/caffeine/MetricsStatsCounter.java
new file mode 100644
index 0000000..4056fe6
--- /dev/null
+++ b/metrics-caffeine/src/main/java/com/codahale/metrics/caffeine/MetricsStatsCounter.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2016 Ben Manes. All Rights Reserved.
+ *
+ * Licensed 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 com.codahale.metrics.caffeine;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.EnumMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.LongAdder;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.github.benmanes.caffeine.cache.stats.CacheStats;
+import com.github.benmanes.caffeine.cache.stats.StatsCounter;
+import org.checkerframework.checker.index.qual.NonNegative;
+
+/**
+ * A {@link StatsCounter} instrumented with Dropwizard Metrics.
+ *
+ * @author ben.manes@gmail.com (Ben Manes)
+ * @author John Karp
+ */
+public final class MetricsStatsCounter implements StatsCounter {
+  private final Counter hitCount;
+  private final Counter missCount;
+  private final Timer loadSuccess;
+  private final Timer loadFailure;
+  private final Histogram evictions;
+  private final Counter evictionWeight;
+  private final EnumMap<RemovalCause, Histogram> evictionsWithCause;
+
+  // for implementing snapshot()
+  private final LongAdder totalLoadTime = new LongAdder();
+
+  /**
+   * Constructs an instance for use by a single cache.
+   *
+   * @param registry the registry of metric instances
+   * @param metricsPrefix the prefix name for the metrics
+   */
+  public MetricsStatsCounter(MetricRegistry registry, String metricsPrefix) {
+    requireNonNull(metricsPrefix);
+    hitCount = registry.counter(MetricRegistry.name(metricsPrefix, "hits"));
+    missCount = registry.counter(MetricRegistry.name(metricsPrefix, "misses"));
+    loadSuccess = registry.timer(MetricRegistry.name(metricsPrefix, "loads-success"));
+    loadFailure = registry.timer(MetricRegistry.name(metricsPrefix, "loads-failure"));
+    evictions = registry.histogram(MetricRegistry.name(metricsPrefix, "evictions"));
+    evictionWeight = registry.counter(MetricRegistry.name(metricsPrefix, "evictions-weight"));
+
+    evictionsWithCause = new EnumMap<>(RemovalCause.class);
+    for (RemovalCause cause : RemovalCause.values()) {
+      evictionsWithCause.put(
+          cause,
+          registry.histogram(MetricRegistry.name(metricsPrefix, "evictions", cause.name())));
+    }
+  }
+
+  @Override
+  public void recordHits(int count) {
+    hitCount.inc(count);
+  }
+
+  @Override
+  public void recordMisses(int count) {
+    missCount.inc(count);
+  }
+
+  @Override
+  public void recordLoadSuccess(long loadTime) {
+    loadSuccess.update(loadTime, TimeUnit.NANOSECONDS);
+    totalLoadTime.add(loadTime);
+  }
+
+  @Override
+  public void recordLoadFailure(long loadTime) {
+    loadFailure.update(loadTime, TimeUnit.NANOSECONDS);
+    totalLoadTime.add(loadTime);
+  }
+
+  // @Override -- Caffeine 2.x
+  @Deprecated
+  @SuppressWarnings("deprecation")
+  public void recordEviction() {
+    // This method is scheduled for removal in version 3.0 in favor of recordEviction(weight)
+    recordEviction(1);
+  }
+
+  // @Override -- Caffeine 2.x
+  @Deprecated
+  @SuppressWarnings("deprecation")
+  public void recordEviction(int weight) {
+    evictions.update(weight);
+    evictionWeight.inc(weight);
+  }
+
+  @Override
+  public void recordEviction(@NonNegative int weight, RemovalCause cause) {
+    evictionsWithCause.get(cause).update(weight);
+    evictionWeight.inc(weight);
+  }
+
+  @Override
+  public CacheStats snapshot() {
+    return CacheStats.of(
+        hitCount.getCount(),
+        missCount.getCount(),
+        loadSuccess.getCount(),
+        loadFailure.getCount(),
+        totalLoadTime.sum(),
+        evictions.getCount(),
+        evictionWeight.getCount());
+  }
+
+  @Override
+  public String toString() {
+    return snapshot().toString();
+  }
+}
diff --git a/metrics-caffeine/src/test/java/com/codahale/metrics/caffeine/MetricsStatsCounterTest.java b/metrics-caffeine/src/test/java/com/codahale/metrics/caffeine/MetricsStatsCounterTest.java
new file mode 100644
index 0000000..2547df6
--- /dev/null
+++ b/metrics-caffeine/src/test/java/com/codahale/metrics/caffeine/MetricsStatsCounterTest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2016 Ben Manes. All Rights Reserved.
+ *
+ * Licensed 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 com.codahale.metrics.caffeine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import com.codahale.metrics.MetricRegistry;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * An example of exporting stats to Dropwizard Metrics (http://metrics.dropwizard.io).
+ *
+ * @author ben.manes@gmail.com (Ben Manes)
+ * @author John Karp
+ */
+public final class MetricsStatsCounterTest {
+
+  private static final String PREFIX = "foo";
+
+  private MetricsStatsCounter stats;
+  private MetricRegistry registry;
+
+  @Before
+  public void setUp() {
+    registry = new MetricRegistry();
+    stats = new MetricsStatsCounter(registry, PREFIX);
+  }
+
+  @Test
+  public void basicUsage() {
+    LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
+        .recordStats(() -> new MetricsStatsCounter(registry, PREFIX))
+        .build(key -> key);
+
+    // Perform application work
+    for (int i = 0; i < 4; i++) {
+      cache.get(1);
+    }
+
+    assertEquals(3L, cache.stats().hitCount());
+    assertEquals(3L, registry.counter(PREFIX + ".hits").getCount());
+  }
+
+  @Test
+  public void hit() {
+    stats.recordHits(2);
+    assertThat(registry.counter(PREFIX + ".hits").getCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void miss() {
+    stats.recordMisses(2);
+    assertThat(registry.counter(PREFIX + ".misses").getCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void loadSuccess() {
+    stats.recordLoadSuccess(256);
+    assertThat(registry.timer(PREFIX + ".loads-success").getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void loadFailure() {
+    stats.recordLoadFailure(256);
+    assertThat(registry.timer(PREFIX + ".loads-failure").getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void eviction() {
+    stats.recordEviction();
+    assertThat(registry.histogram(PREFIX + ".evictions").getCount()).isEqualTo(1);
+    assertThat(registry.counter(PREFIX + ".evictions-weight").getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void evictionWithWeight() {
+    stats.recordEviction(3);
+    assertThat(registry.histogram(PREFIX + ".evictions").getCount()).isEqualTo(1);
+    assertThat(registry.counter(PREFIX + ".evictions-weight").getCount()).isEqualTo(3);
+  }
+
+  @Test
+  public void evictionWithCause() {
+    // With JUnit 5, this would be better done with @ParameterizedTest + @EnumSource
+    for (RemovalCause cause : RemovalCause.values()) {
+      stats.recordEviction(3, cause);
+      assertThat(registry.histogram(PREFIX + ".evictions." + cause.name()).getCount()).isEqualTo(1);
+    }
+  }
+}
diff --git a/metrics-caffeine3/pom.xml b/metrics-caffeine3/pom.xml
new file mode 100644
index 0000000..a554d82
--- /dev/null
+++ b/metrics-caffeine3/pom.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-caffeine3</artifactId>
+    <name>Metrics Integration for Caffeine 3.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        Metrics Integration for Caffeine 3.x.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.caffeine3</javaModuleName>
+
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>com.github.ben-manes.caffeine</groupId>
+                <artifactId>caffeine</artifactId>
+                <version>3.1.8</version>
+            </dependency>
+            <dependency>
+                <groupId>org.checkerframework</groupId>
+                <artifactId>checker-qual</artifactId>
+                <version>3.42.0</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.checkerframework</groupId>
+            <artifactId>checker-qual</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-caffeine3/src/main/java/io/dropwizard/metrics/caffeine3/MetricsStatsCounter.java b/metrics-caffeine3/src/main/java/io/dropwizard/metrics/caffeine3/MetricsStatsCounter.java
new file mode 100644
index 0000000..ef37bbd
--- /dev/null
+++ b/metrics-caffeine3/src/main/java/io/dropwizard/metrics/caffeine3/MetricsStatsCounter.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2016 Ben Manes. All Rights Reserved.
+ *
+ * Licensed 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 io.dropwizard.metrics.caffeine3;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.EnumMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.LongAdder;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.github.benmanes.caffeine.cache.stats.CacheStats;
+import com.github.benmanes.caffeine.cache.stats.StatsCounter;
+import org.checkerframework.checker.index.qual.NonNegative;
+
+/**
+ * A {@link StatsCounter} instrumented with Dropwizard Metrics.
+ *
+ * @author ben.manes@gmail.com (Ben Manes)
+ * @author John Karp
+ */
+public final class MetricsStatsCounter implements StatsCounter {
+  private final Counter hitCount;
+  private final Counter missCount;
+  private final Timer loadSuccess;
+  private final Timer loadFailure;
+  private final Counter evictionWeight;
+  private final EnumMap<RemovalCause, Histogram> evictionsWithCause;
+
+  // for implementing snapshot()
+  private final LongAdder totalLoadTime = new LongAdder();
+
+  /**
+   * Constructs an instance for use by a single cache.
+   *
+   * @param registry the registry of metric instances
+   * @param metricsPrefix the prefix name for the metrics
+   */
+  public MetricsStatsCounter(MetricRegistry registry, String metricsPrefix) {
+    requireNonNull(metricsPrefix);
+    hitCount = registry.counter(MetricRegistry.name(metricsPrefix, "hits"));
+    missCount = registry.counter(MetricRegistry.name(metricsPrefix, "misses"));
+    loadSuccess = registry.timer(MetricRegistry.name(metricsPrefix, "loads-success"));
+    loadFailure = registry.timer(MetricRegistry.name(metricsPrefix, "loads-failure"));
+    evictionWeight = registry.counter(MetricRegistry.name(metricsPrefix, "evictions-weight"));
+
+    evictionsWithCause = new EnumMap<>(RemovalCause.class);
+    for (RemovalCause cause : RemovalCause.values()) {
+      evictionsWithCause.put(
+          cause,
+          registry.histogram(MetricRegistry.name(metricsPrefix, "evictions", cause.name())));
+    }
+  }
+
+  @Override
+  public void recordHits(int count) {
+    hitCount.inc(count);
+  }
+
+  @Override
+  public void recordMisses(int count) {
+    missCount.inc(count);
+  }
+
+  @Override
+  public void recordLoadSuccess(long loadTime) {
+    loadSuccess.update(loadTime, TimeUnit.NANOSECONDS);
+    totalLoadTime.add(loadTime);
+  }
+
+  @Override
+  public void recordLoadFailure(long loadTime) {
+    loadFailure.update(loadTime, TimeUnit.NANOSECONDS);
+    totalLoadTime.add(loadTime);
+  }
+
+  @Override
+  public void recordEviction(@NonNegative int weight, RemovalCause cause) {
+    evictionsWithCause.get(cause).update(weight);
+    evictionWeight.inc(weight);
+  }
+
+  @Override
+  public CacheStats snapshot() {
+    return CacheStats.of(
+        hitCount.getCount(),
+        missCount.getCount(),
+        loadSuccess.getCount(),
+        loadFailure.getCount(),
+        totalLoadTime.sum(),
+        evictionsWithCause.values().stream().mapToLong(Histogram::getCount).sum(),
+        evictionWeight.getCount());
+  }
+
+  @Override
+  public String toString() {
+    return snapshot().toString();
+  }
+}
diff --git a/metrics-caffeine3/src/test/java/io/dropwizard/metrics/caffeine3/MetricsStatsCounterTest.java b/metrics-caffeine3/src/test/java/io/dropwizard/metrics/caffeine3/MetricsStatsCounterTest.java
new file mode 100644
index 0000000..4f412db
--- /dev/null
+++ b/metrics-caffeine3/src/test/java/io/dropwizard/metrics/caffeine3/MetricsStatsCounterTest.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2016 Ben Manes. All Rights Reserved.
+ *
+ * Licensed 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 io.dropwizard.metrics.caffeine3;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import com.codahale.metrics.MetricRegistry;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * An example of exporting stats to  <a href="https://metrics.dropwizard.io">Dropwizard Metrics</a>.
+ *
+ * @author ben.manes@gmail.com (Ben Manes)
+ * @author John Karp
+ */
+public final class MetricsStatsCounterTest {
+
+  private static final String PREFIX = "foo";
+
+  private MetricsStatsCounter stats;
+  private MetricRegistry registry;
+
+  @Before
+  public void setUp() {
+    registry = new MetricRegistry();
+    stats = new MetricsStatsCounter(registry, PREFIX);
+  }
+
+  @Test
+  public void basicUsage() {
+    LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
+        .recordStats(() -> new MetricsStatsCounter(registry, PREFIX))
+        .build(key -> key);
+
+    // Perform application work
+    for (int i = 0; i < 4; i++) {
+      Integer unused = cache.get(1);
+    }
+
+    assertEquals(3L, cache.stats().hitCount());
+    assertEquals(3L, registry.counter(PREFIX + ".hits").getCount());
+  }
+
+  @Test
+  public void hit() {
+    stats.recordHits(2);
+    assertThat(registry.counter(PREFIX + ".hits").getCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void miss() {
+    stats.recordMisses(2);
+    assertThat(registry.counter(PREFIX + ".misses").getCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void loadSuccess() {
+    stats.recordLoadSuccess(256);
+    assertThat(registry.timer(PREFIX + ".loads-success").getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void loadFailure() {
+    stats.recordLoadFailure(256);
+    assertThat(registry.timer(PREFIX + ".loads-failure").getCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void evictionWithCause() {
+    // With JUnit 5, this would be better done with @ParameterizedTest + @EnumSource
+    for (RemovalCause cause : RemovalCause.values()) {
+      stats.recordEviction(3, cause);
+      assertThat(registry.histogram(PREFIX + ".evictions." + cause.name()).getCount()).isEqualTo(1);
+    }
+  }
+}
diff --git a/metrics-collectd/pom.xml b/metrics-collectd/pom.xml
new file mode 100644
index 0000000..f89713f
--- /dev/null
+++ b/metrics-collectd/pom.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-collectd</artifactId>
+    <name>Metrics Integration for Collectd</name>
+    <packaging>bundle</packaging>
+    <description>
+        A reporter for Metrics which announces measurements to Collectd.
+    </description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.collectd</javaModuleName>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.hardis.collectd</groupId>
+            <artifactId>jcollectd</artifactId>
+            <version>1.0.3</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-collectd/src/main/java/com/codahale/metrics/collectd/CollectdReporter.java b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/CollectdReporter.java
new file mode 100644
index 0000000..5665f39
--- /dev/null
+++ b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/CollectdReporter.java
@@ -0,0 +1,337 @@
+package com.codahale.metrics.collectd;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricAttribute;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricAttribute.COUNT;
+import static com.codahale.metrics.MetricAttribute.M15_RATE;
+import static com.codahale.metrics.MetricAttribute.M1_RATE;
+import static com.codahale.metrics.MetricAttribute.M5_RATE;
+import static com.codahale.metrics.MetricAttribute.MAX;
+import static com.codahale.metrics.MetricAttribute.MEAN;
+import static com.codahale.metrics.MetricAttribute.MEAN_RATE;
+import static com.codahale.metrics.MetricAttribute.MIN;
+import static com.codahale.metrics.MetricAttribute.P50;
+import static com.codahale.metrics.MetricAttribute.P75;
+import static com.codahale.metrics.MetricAttribute.P95;
+import static com.codahale.metrics.MetricAttribute.P98;
+import static com.codahale.metrics.MetricAttribute.P99;
+import static com.codahale.metrics.MetricAttribute.P999;
+import static com.codahale.metrics.MetricAttribute.STDDEV;
+
+/**
+ * A reporter which publishes metric values to a Collectd server.
+ *
+ * @see <a href="https://collectd.org">collectd – The system statistics
+ * collection daemon</a>
+ */
+public class CollectdReporter extends ScheduledReporter {
+
+    /**
+     * Returns a builder for the specified registry.
+     * <p>
+     * The default settings are:
+     * <ul>
+     * <li>hostName: InetAddress.getLocalHost().getHostName()</li>
+     * <li>executor: default executor created by {@code ScheduledReporter}</li>
+     * <li>shutdownExecutorOnStop: true</li>
+     * <li>clock: Clock.defaultClock()</li>
+     * <li>rateUnit: TimeUnit.SECONDS</li>
+     * <li>durationUnit: TimeUnit.MILLISECONDS</li>
+     * <li>filter: MetricFilter.ALL</li>
+     * <li>securityLevel: NONE</li>
+     * <li>username: ""</li>
+     * <li>password: ""</li>
+     * </ul>
+     */
+    public static Builder forRegistry(MetricRegistry registry) {
+        return new Builder(registry);
+    }
+
+    public static class Builder {
+
+        private final MetricRegistry registry;
+        private String hostName;
+        private ScheduledExecutorService executor;
+        private boolean shutdownExecutorOnStop = true;
+        private Clock clock = Clock.defaultClock();
+        private TimeUnit rateUnit = TimeUnit.SECONDS;
+        private TimeUnit durationUnit = TimeUnit.MILLISECONDS;
+        private MetricFilter filter = MetricFilter.ALL;
+        private SecurityLevel securityLevel = SecurityLevel.NONE;
+        private String username = "";
+        private String password = "";
+        private Set<MetricAttribute> disabledMetricAttributes = Collections.emptySet();
+        private int maxLength = Sanitize.DEFAULT_MAX_LENGTH;
+
+        private Builder(MetricRegistry registry) {
+            this.registry = registry;
+        }
+
+        public Builder withHostName(String hostName) {
+            this.hostName = hostName;
+            return this;
+        }
+
+        public Builder shutdownExecutorOnStop(boolean shutdownExecutorOnStop) {
+            this.shutdownExecutorOnStop = shutdownExecutorOnStop;
+            return this;
+        }
+
+        public Builder scheduleOn(ScheduledExecutorService executor) {
+            this.executor = executor;
+            return this;
+        }
+
+        public Builder withClock(Clock clock) {
+            this.clock = clock;
+            return this;
+        }
+
+        public Builder convertRatesTo(TimeUnit rateUnit) {
+            this.rateUnit = rateUnit;
+            return this;
+        }
+
+        public Builder convertDurationsTo(TimeUnit durationUnit) {
+            this.durationUnit = durationUnit;
+            return this;
+        }
+
+        public Builder filter(MetricFilter filter) {
+            this.filter = filter;
+            return this;
+        }
+
+        public Builder withUsername(String username) {
+            this.username = username;
+            return this;
+        }
+
+        public Builder withPassword(String password) {
+            this.password = password;
+            return this;
+        }
+
+        public Builder withSecurityLevel(SecurityLevel securityLevel) {
+            this.securityLevel = securityLevel;
+            return this;
+        }
+
+        public Builder disabledMetricAttributes(Set<MetricAttribute> attributes) {
+            this.disabledMetricAttributes = attributes;
+            return this;
+        }
+
+        public Builder withMaxLength(int maxLength) {
+            this.maxLength = maxLength;
+            return this;
+        }
+
+        public CollectdReporter build(Sender sender) {
+            if (securityLevel != SecurityLevel.NONE) {
+                if (username.isEmpty()) {
+                    throw new IllegalArgumentException("username is required for securityLevel: " + securityLevel);
+                }
+                if (password.isEmpty()) {
+                    throw new IllegalArgumentException("password is required for securityLevel: " + securityLevel);
+                }
+            }
+            return new CollectdReporter(registry,
+                    hostName, sender,
+                    executor, shutdownExecutorOnStop,
+                    clock, rateUnit, durationUnit,
+                    filter, disabledMetricAttributes,
+                    username, password, securityLevel, new Sanitize(maxLength));
+        }
+    }
+
+    private static final Logger LOG = LoggerFactory.getLogger(CollectdReporter.class);
+    private static final String REPORTER_NAME = "collectd-reporter";
+    private static final String FALLBACK_HOST_NAME = "localhost";
+    private static final String COLLECTD_TYPE_GAUGE = "gauge";
+
+    private String hostName;
+    private final Sender sender;
+    private final Clock clock;
+    private long period;
+    private final PacketWriter writer;
+    private final Sanitize sanitize;
+
+    private CollectdReporter(MetricRegistry registry,
+                             String hostname, Sender sender,
+                             ScheduledExecutorService executor, boolean shutdownExecutorOnStop,
+                             Clock clock, TimeUnit rateUnit, TimeUnit durationUnit,
+                             MetricFilter filter, Set<MetricAttribute> disabledMetricAttributes,
+                             String username, String password,
+                             SecurityLevel securityLevel, Sanitize sanitize) {
+        super(registry, REPORTER_NAME, filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop,
+                disabledMetricAttributes);
+        this.hostName = (hostname != null) ? hostname : resolveHostName();
+        this.sender = sender;
+        this.clock = clock;
+        this.sanitize = sanitize;
+        writer = new PacketWriter(sender, username, password, securityLevel);
+    }
+
+    private String resolveHostName() {
+        try {
+            return InetAddress.getLocalHost().getHostName();
+        } catch (Exception e) {
+            LOG.error("Failed to lookup local host name: {}", e.getMessage(), e);
+            return FALLBACK_HOST_NAME;
+        }
+    }
+
+    @Override
+    public void start(long period, TimeUnit unit) {
+        this.period = period;
+        super.start(period, unit);
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public void report(SortedMap<String, Gauge> gauges, SortedMap<String, Counter> counters,
+            SortedMap<String, Histogram> histograms, SortedMap<String, Meter> meters, SortedMap<String, Timer> timers) {
+        MetaData.Builder metaData = new MetaData.Builder(sanitize, hostName, clock.getTime() / 1000, period)
+                .type(COLLECTD_TYPE_GAUGE);
+        try {
+            connect(sender);
+            for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
+                serializeGauge(metaData.plugin(entry.getKey()), entry.getValue());
+            }
+            for (Map.Entry<String, Counter> entry : counters.entrySet()) {
+                serializeCounter(metaData.plugin(entry.getKey()), entry.getValue());
+            }
+            for (Map.Entry<String, Histogram> entry : histograms.entrySet()) {
+                serializeHistogram(metaData.plugin(entry.getKey()), entry.getValue());
+            }
+            for (Map.Entry<String, Meter> entry : meters.entrySet()) {
+                serializeMeter(metaData.plugin(entry.getKey()), entry.getValue());
+            }
+            for (Map.Entry<String, Timer> entry : timers.entrySet()) {
+                serializeTimer(metaData.plugin(entry.getKey()), entry.getValue());
+            }
+        } catch (IOException e) {
+            LOG.warn("Unable to report to Collectd", e);
+        } finally {
+            disconnect(sender);
+        }
+    }
+
+    private void connect(Sender sender) throws IOException {
+        if (!sender.isConnected()) {
+            sender.connect();
+        }
+    }
+
+    private void disconnect(Sender sender) {
+        try {
+            sender.disconnect();
+        } catch (Exception e) {
+            LOG.warn("Error disconnecting from Collectd", e);
+        }
+    }
+
+    private void writeValue(MetaData.Builder metaData, MetricAttribute attribute, Number value) {
+        if (!getDisabledMetricAttributes().contains(attribute)) {
+            write(metaData.typeInstance(attribute.getCode()).get(), value);
+        }
+    }
+
+    private void writeRate(MetaData.Builder metaData, MetricAttribute attribute, double rate) {
+        writeValue(metaData, attribute, convertRate(rate));
+    }
+
+    private void writeDuration(MetaData.Builder metaData, MetricAttribute attribute, double duration) {
+        writeValue(metaData, attribute, convertDuration(duration));
+    }
+
+    private void write(MetaData metaData, Number value) {
+        try {
+            writer.write(metaData, value);
+        } catch (RuntimeException e) {
+            LOG.warn("Failed to process metric '" + metaData.getPlugin() + "': " + e.getMessage());
+        } catch (IOException e) {
+            LOG.error("Failed to send metric to collectd", e);
+        }
+    }
+
+    @SuppressWarnings("rawtypes")
+    private void serializeGauge(MetaData.Builder metaData, Gauge metric) {
+        if (metric.getValue() instanceof Number) {
+            write(metaData.typeInstance("value").get(), (Number) metric.getValue());
+        } else if (metric.getValue() instanceof Boolean) {
+            write(metaData.typeInstance("value").get(), ((Boolean) metric.getValue()) ? 1 : 0);
+        } else {
+            LOG.warn("Failed to process metric '{}'. Unsupported gauge of type: {} ", metaData.get().getPlugin(),
+                    metric.getValue().getClass().getName());
+        }
+    }
+
+    private void serializeMeter(MetaData.Builder metaData, Meter metric) {
+        writeValue(metaData, COUNT, (double) metric.getCount());
+        writeRate(metaData, M1_RATE, metric.getOneMinuteRate());
+        writeRate(metaData, M5_RATE, metric.getFiveMinuteRate());
+        writeRate(metaData, M15_RATE, metric.getFifteenMinuteRate());
+        writeRate(metaData, MEAN_RATE, metric.getMeanRate());
+    }
+
+    private void serializeCounter(MetaData.Builder metaData, Counter metric) {
+        writeValue(metaData, COUNT, (double) metric.getCount());
+    }
+
+    private void serializeHistogram(MetaData.Builder metaData, Histogram metric) {
+        final Snapshot snapshot = metric.getSnapshot();
+        writeValue(metaData, COUNT, (double) metric.getCount());
+        writeValue(metaData, MAX, (double) snapshot.getMax());
+        writeValue(metaData, MEAN, snapshot.getMean());
+        writeValue(metaData, MIN, (double) snapshot.getMin());
+        writeValue(metaData, STDDEV, snapshot.getStdDev());
+        writeValue(metaData, P50, snapshot.getMedian());
+        writeValue(metaData, P75, snapshot.get75thPercentile());
+        writeValue(metaData, P95, snapshot.get95thPercentile());
+        writeValue(metaData, P98, snapshot.get98thPercentile());
+        writeValue(metaData, P99, snapshot.get99thPercentile());
+        writeValue(metaData, P999, snapshot.get999thPercentile());
+    }
+
+    private void serializeTimer(MetaData.Builder metaData, Timer metric) {
+        final Snapshot snapshot = metric.getSnapshot();
+        writeValue(metaData, COUNT, (double) metric.getCount());
+        writeDuration(metaData, MAX, (double) snapshot.getMax());
+        writeDuration(metaData, MEAN, snapshot.getMean());
+        writeDuration(metaData, MIN, (double) snapshot.getMin());
+        writeDuration(metaData, STDDEV, snapshot.getStdDev());
+        writeDuration(metaData, P50, snapshot.getMedian());
+        writeDuration(metaData, P75, snapshot.get75thPercentile());
+        writeDuration(metaData, P95, snapshot.get95thPercentile());
+        writeDuration(metaData, P98, snapshot.get98thPercentile());
+        writeDuration(metaData, P99, snapshot.get99thPercentile());
+        writeDuration(metaData, P999, snapshot.get999thPercentile());
+        writeRate(metaData, M1_RATE, metric.getOneMinuteRate());
+        writeRate(metaData, M5_RATE, metric.getFiveMinuteRate());
+        writeRate(metaData, M15_RATE, metric.getFifteenMinuteRate());
+        writeRate(metaData, MEAN_RATE, metric.getMeanRate());
+    }
+}
diff --git a/metrics-collectd/src/main/java/com/codahale/metrics/collectd/MetaData.java b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/MetaData.java
new file mode 100644
index 0000000..8f7e239
--- /dev/null
+++ b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/MetaData.java
@@ -0,0 +1,98 @@
+package com.codahale.metrics.collectd;
+
+class MetaData {
+
+    private final String host;
+    private final String plugin;
+    private final String pluginInstance;
+    private final String type;
+    private final String typeInstance;
+    private final long timestamp;
+    private final long period;
+
+    MetaData(String host, String plugin, String pluginInstance, String type, String typeInstance,
+             long timestamp, long period) {
+        this.host = host;
+        this.plugin = plugin;
+        this.pluginInstance = pluginInstance;
+        this.type = type;
+        this.typeInstance = typeInstance;
+        this.timestamp = timestamp;
+        this.period = period;
+    }
+
+    String getHost() {
+        return host;
+    }
+
+    String getPlugin() {
+        return plugin;
+    }
+
+    String getPluginInstance() {
+        return pluginInstance;
+    }
+
+    String getType() {
+        return type;
+    }
+
+    String getTypeInstance() {
+        return typeInstance;
+    }
+
+    long getTimestamp() {
+        return timestamp;
+    }
+
+    long getPeriod() {
+        return period;
+    }
+
+    static class Builder {
+
+        private String host;
+        private String plugin;
+        private String pluginInstance;
+        private String type;
+        private String typeInstance;
+        private long timestamp;
+        private long period;
+        private Sanitize sanitize;
+
+        Builder(String host, long timestamp, long duration) {
+            this(new Sanitize(Sanitize.DEFAULT_MAX_LENGTH), host, timestamp, duration);
+        }
+
+        Builder(Sanitize sanitize, String host, long timestamp, long duration) {
+            this.sanitize = sanitize;
+            this.host = sanitize.instanceName(host);
+            this.timestamp = timestamp;
+            period = duration;
+        }
+
+        Builder plugin(String name) {
+            plugin = sanitize.name(name);
+            return this;
+        }
+
+        Builder pluginInstance(String name) {
+            pluginInstance = sanitize.instanceName(name);
+            return this;
+        }
+
+        Builder type(String name) {
+            type = sanitize.name(name);
+            return this;
+        }
+
+        Builder typeInstance(String name) {
+            typeInstance = sanitize.instanceName(name);
+            return this;
+        }
+
+        MetaData get() {
+            return new MetaData(host, plugin, pluginInstance, type, typeInstance, timestamp, period);
+        }
+    }
+}
diff --git a/metrics-collectd/src/main/java/com/codahale/metrics/collectd/PacketWriter.java b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/PacketWriter.java
new file mode 100644
index 0000000..a19efe0
--- /dev/null
+++ b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/PacketWriter.java
@@ -0,0 +1,275 @@
+package com.codahale.metrics.collectd;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.Mac;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.ShortBufferException;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.nio.BufferOverflowException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidParameterSpecException;
+import java.util.Arrays;
+
+class PacketWriter {
+
+    private static final int TYPE_HOST = 0;
+    private static final int TYPE_TIME = 1;
+    private static final int TYPE_PLUGIN = 2;
+    private static final int TYPE_PLUGIN_INSTANCE = 3;
+    private static final int TYPE_TYPE = 4;
+    private static final int TYPE_TYPE_INSTANCE = 5;
+    private static final int TYPE_VALUES = 6;
+    private static final int TYPE_INTERVAL = 7;
+    private static final int TYPE_SIGN_SHA256 = 0x0200;
+    private static final int TYPE_ENCR_AES256 = 0x0210;
+
+    private static final int UINT16_LEN = 2;
+    private static final int UINT32_LEN = UINT16_LEN * 2;
+    private static final int UINT64_LEN = UINT32_LEN * 2;
+    private static final int HEADER_LEN = UINT16_LEN * 2;
+    private static final int BUFFER_SIZE = 1024;
+
+    private static final int VALUE_COUNT_LEN = UINT16_LEN;
+    private static final int NUMBER_LEN = HEADER_LEN + UINT64_LEN;
+    private static final int SIGNATURE_LEN = 36;      // 2b Type + 2b Length + 32b Hash
+    private static final int ENCRYPT_DATA_LEN = 22;   // 16b IV + 2b Type + 2b Length + 2b Username length
+    private static final int IV_LENGTH = 16;
+    private static final int SHA1_LENGTH = 20;
+
+    private static final int VALUE_LEN = 9;
+    private static final byte DATA_TYPE_GAUGE = (byte) 1;
+    private static final byte NULL = (byte) '\0';
+    private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";
+    private static final String AES_CYPHER = "AES_256/OFB/NoPadding";
+    private static final String AES = "AES";
+    private static final String SHA_256_ALGORITHM = "SHA-256";
+    private static final String SHA_1_ALGORITHM = "SHA1";
+
+    private final Sender sender;
+
+    private final SecurityLevel securityLevel;
+    private final byte[] username;
+    private final byte[] password;
+
+    PacketWriter(Sender sender, String username, String password, SecurityLevel securityLevel) {
+        this.sender = sender;
+        this.securityLevel = securityLevel;
+        this.username = username != null ? username.getBytes(StandardCharsets.UTF_8) : null;
+        this.password = password != null ? password.getBytes(StandardCharsets.UTF_8) : null;
+    }
+
+    void write(MetaData metaData, Number... values) throws BufferOverflowException, IOException {
+        final ByteBuffer packet = ByteBuffer.allocate(BUFFER_SIZE);
+        write(packet, metaData);
+        write(packet, values);
+        packet.flip();
+
+        switch (securityLevel) {
+            case NONE:
+                sender.send(packet);
+                break;
+            case SIGN:
+                sender.send(signPacket(packet));
+                break;
+            case ENCRYPT:
+                sender.send(encryptPacket(packet));
+                break;
+            default:
+                throw new IllegalArgumentException("Unsupported security level: " + securityLevel);
+        }
+    }
+
+
+    private void write(ByteBuffer buffer, MetaData metaData) {
+        writeString(buffer, TYPE_HOST, metaData.getHost());
+        writeNumber(buffer, TYPE_TIME, metaData.getTimestamp());
+        writeString(buffer, TYPE_PLUGIN, metaData.getPlugin());
+        writeString(buffer, TYPE_PLUGIN_INSTANCE, metaData.getPluginInstance());
+        writeString(buffer, TYPE_TYPE, metaData.getType());
+        writeString(buffer, TYPE_TYPE_INSTANCE, metaData.getTypeInstance());
+        writeNumber(buffer, TYPE_INTERVAL, metaData.getPeriod());
+    }
+
+    private void write(ByteBuffer buffer, Number... values) {
+        final int numValues = values.length;
+        final int length = HEADER_LEN + VALUE_COUNT_LEN + numValues * VALUE_LEN;
+        writeHeader(buffer, TYPE_VALUES, length);
+        buffer.putShort((short) numValues);
+        buffer.put(nCopies(numValues, DATA_TYPE_GAUGE));
+        buffer.order(ByteOrder.LITTLE_ENDIAN);
+        for (Number value : values) {
+            buffer.putDouble(value.doubleValue());
+        }
+        buffer.order(ByteOrder.BIG_ENDIAN);
+    }
+
+    private byte[] nCopies(int n, byte value) {
+        final byte[] array = new byte[n];
+        Arrays.fill(array, value);
+        return array;
+    }
+
+    private void writeString(ByteBuffer buffer, int type, String val) {
+        if (val == null || val.length() == 0) {
+            return;
+        }
+        int len = HEADER_LEN + val.length() + 1;
+        writeHeader(buffer, type, len);
+        buffer.put(val.getBytes(StandardCharsets.US_ASCII)).put(NULL);
+    }
+
+    private void writeNumber(ByteBuffer buffer, int type, long val) {
+        writeHeader(buffer, type, NUMBER_LEN);
+        buffer.putLong(val);
+    }
+
+    private void writeHeader(ByteBuffer buffer, int type, int len) {
+        buffer.putShort((short) type);
+        buffer.putShort((short) len);
+    }
+
+    /**
+     * Signs the provided packet, so a CollectD server can verify that its authenticity.
+     * Wire format:
+     * <pre>
+     * +-------------------------------+-------------------------------+
+     * ! Type (0x0200)                 ! Length                        !
+     * +-------------------------------+-------------------------------+
+     * ! Signature (SHA2(username + packet))                           \
+     * +-------------------------------+-------------------------------+
+     * ! Username                      ! Packet                        \
+     * +---------------------------------------------------------------+
+     * </pre>
+     *
+     * @see <a href="https://collectd.org/wiki/index.php/Binary_protocol#Signature_part">
+     * Binary protocol - CollectD | Signature part</a>
+     */
+    private ByteBuffer signPacket(ByteBuffer packet) {
+        final byte[] signature = sign(password, (ByteBuffer) ByteBuffer.allocate(packet.remaining() + username.length)
+                .put(username)
+                .put(packet)
+                .flip());
+        return (ByteBuffer) ByteBuffer.allocate(BUFFER_SIZE)
+                .putShort((short) TYPE_SIGN_SHA256)
+                .putShort((short) (username.length + SIGNATURE_LEN))
+                .put(signature)
+                .put(username)
+                .put((ByteBuffer) packet.flip())
+                .flip();
+    }
+
+    /**
+     * Encrypts the provided packet, so it's can't be eavesdropped during a transfer
+     * to a CollectD server. Wire format:
+     * <pre>
+     * +---------------------------------+-------------------------------+
+     * ! Type (0x0210)                   ! Length                        !
+     * +---------------------------------+-------------------------------+
+     * ! Username length in bytes        ! Username                      \
+     * +-----------------------------------------------------------------+
+     * ! Initialization Vector (IV)      !                               \
+     * +---------------------------------+-------------------------------+
+     * ! Encrypted bytes (AES (SHA1(packet) + packet))                   \
+     * +---------------------------------+-------------------------------+
+     * </pre>
+     *
+     * @see <a href="https://collectd.org/wiki/index.php/Binary_protocol#Encrypted_part">
+     * Binary protocol - CollectD | Encrypted part</a>
+     */
+    private ByteBuffer encryptPacket(ByteBuffer packet) {
+        final ByteBuffer payload = (ByteBuffer) ByteBuffer.allocate(SHA1_LENGTH + packet.remaining())
+                .put(sha1(packet))
+                .put((ByteBuffer) packet.flip())
+                .flip();
+        final EncryptionResult er = encrypt(password, payload);
+        return (ByteBuffer) ByteBuffer.allocate(BUFFER_SIZE)
+                .putShort((short) TYPE_ENCR_AES256)
+                .putShort((short) (ENCRYPT_DATA_LEN + username.length + er.output.remaining()))
+                .putShort((short) username.length)
+                .put(username)
+                .put(er.iv)
+                .put(er.output)
+                .flip();
+    }
+
+    private static byte[] sign(byte[] secret, ByteBuffer input) {
+        final Mac mac;
+        try {
+            mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
+            mac.init(new SecretKeySpec(secret, HMAC_SHA256_ALGORITHM));
+        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+            throw new RuntimeException(e);
+        }
+        mac.update(input);
+        return mac.doFinal();
+    }
+
+    private static EncryptionResult encrypt(byte[] password, ByteBuffer input) {
+        final Cipher cipher;
+        try {
+            cipher = Cipher.getInstance(AES_CYPHER);
+            cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(sha256(password), AES));
+        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
+            throw new RuntimeException(e);
+        }
+        final byte[] iv;
+        try {
+            iv = cipher.getParameters().getParameterSpec(IvParameterSpec.class).getIV();
+        } catch (InvalidParameterSpecException e) {
+            throw new RuntimeException(e);
+        }
+        if (iv.length != IV_LENGTH) {
+            throw new IllegalStateException("Bad initialization vector");
+        }
+        final ByteBuffer output = ByteBuffer.allocate(input.remaining() * 2);
+        try {
+            cipher.doFinal(input, output);
+        } catch (ShortBufferException | IllegalBlockSizeException | BadPaddingException e) {
+            throw new RuntimeException(e);
+        }
+        return new EncryptionResult(iv, (ByteBuffer) output.flip());
+    }
+
+    private static byte[] sha256(byte[] input) {
+        try {
+            return MessageDigest.getInstance(SHA_256_ALGORITHM).digest(input);
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static byte[] sha1(ByteBuffer input) {
+        try {
+            final MessageDigest digest = MessageDigest.getInstance(SHA_1_ALGORITHM);
+            digest.update(input);
+            final byte[] output = digest.digest();
+            if (output.length != SHA1_LENGTH) {
+                throw new IllegalStateException("Bad SHA1 hash");
+            }
+            return output;
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static class EncryptionResult {
+
+        private final byte[] iv;
+        private final ByteBuffer output;
+
+        private EncryptionResult(byte[] iv, ByteBuffer output) {
+            this.iv = iv;
+            this.output = output;
+        }
+    }
+
+}
diff --git a/metrics-collectd/src/main/java/com/codahale/metrics/collectd/Sanitize.java b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/Sanitize.java
new file mode 100644
index 0000000..156d1f6
--- /dev/null
+++ b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/Sanitize.java
@@ -0,0 +1,46 @@
+package com.codahale.metrics.collectd;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * @see <a href="https://collectd.org/wiki/index.php/Naming_schema>Collectd naming schema</a>
+ */
+class Sanitize {
+
+    static final int DEFAULT_MAX_LENGTH = 63;
+
+    private static final char DASH = '-';
+    private static final char SLASH = '/';
+    private static final char NULL = '\0';
+    private static final char UNDERSCORE = '_';
+
+    private static final List<Character> INSTANCE_RESERVED = Arrays.asList(SLASH, NULL);
+    private static final List<Character> NAME_RESERVED = Arrays.asList(DASH, SLASH, NULL);
+
+    private final int maxLength;
+
+    Sanitize(int maxLength) {
+        this.maxLength = maxLength;
+    }
+
+    String name(String name) {
+        return sanitize(name, NAME_RESERVED);
+    }
+
+    String instanceName(String instanceName) {
+        return sanitize(instanceName, INSTANCE_RESERVED);
+    }
+
+    private String sanitize(String string, List<Character> reservedChars) {
+        final StringBuilder buffer = new StringBuilder(string.length());
+        final int len = Math.min(string.length(), maxLength);
+        for (int i = 0; i < len; i++) {
+            final char c = string.charAt(i);
+            final boolean legal = ((int) c) < 128 && !reservedChars.contains(c);
+            buffer.append(legal ? c : UNDERSCORE);
+        }
+        return buffer.toString();
+    }
+
+}
diff --git a/metrics-collectd/src/main/java/com/codahale/metrics/collectd/SecurityConfiguration.java b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/SecurityConfiguration.java
new file mode 100644
index 0000000..0bb1a27
--- /dev/null
+++ b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/SecurityConfiguration.java
@@ -0,0 +1,30 @@
+package com.codahale.metrics.collectd;
+
+public class SecurityConfiguration {
+
+    private final byte[] username;
+    private final byte[] password;
+    private final SecurityLevel securityLevel;
+
+    public SecurityConfiguration(byte[] username, byte[] password, SecurityLevel securityLevel) {
+        this.username = username;
+        this.password = password;
+        this.securityLevel = securityLevel;
+    }
+
+    public static SecurityConfiguration none() {
+        return new SecurityConfiguration(null, null, SecurityLevel.NONE);
+    }
+
+    public byte[] getUsername() {
+        return username;
+    }
+
+    public byte[] getPassword() {
+        return password;
+    }
+
+    public SecurityLevel getSecurityLevel() {
+        return securityLevel;
+    }
+}
diff --git a/metrics-collectd/src/main/java/com/codahale/metrics/collectd/SecurityLevel.java b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/SecurityLevel.java
new file mode 100644
index 0000000..df819c0
--- /dev/null
+++ b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/SecurityLevel.java
@@ -0,0 +1,8 @@
+package com.codahale.metrics.collectd;
+
+public enum SecurityLevel {
+
+    NONE,
+    SIGN,
+    ENCRYPT
+}
diff --git a/metrics-collectd/src/main/java/com/codahale/metrics/collectd/Sender.java b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/Sender.java
new file mode 100644
index 0000000..fb63f12
--- /dev/null
+++ b/metrics-collectd/src/main/java/com/codahale/metrics/collectd/Sender.java
@@ -0,0 +1,50 @@
+package com.codahale.metrics.collectd;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.DatagramChannel;
+
+public class Sender {
+
+    private final String host;
+    private final int port;
+
+    private InetSocketAddress address;
+    private DatagramChannel channel;
+
+    public Sender(String host, int port) {
+        this.host = host;
+        this.port = port;
+    }
+
+    public void connect() throws IOException {
+        if (isConnected()) {
+            throw new IllegalStateException("Already connected");
+        }
+        if (host != null) {
+            address = new InetSocketAddress(host, port);
+        }
+        channel = DatagramChannel.open();
+    }
+
+    public boolean isConnected() {
+        return channel != null && !channel.socket().isClosed();
+    }
+
+    public void send(ByteBuffer buffer) throws IOException {
+        channel.send(buffer, address);
+    }
+
+    public void disconnect() throws IOException {
+        if (channel == null) {
+            return;
+        }
+        try {
+            channel.close();
+        } finally {
+            channel = null;
+        }
+    }
+
+}
diff --git a/metrics-collectd/src/test/java/com/codahale/metrics/collectd/CollectdReporterSecurityTest.java b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/CollectdReporterSecurityTest.java
new file mode 100644
index 0000000..5bedba4
--- /dev/null
+++ b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/CollectdReporterSecurityTest.java
@@ -0,0 +1,33 @@
+package com.codahale.metrics.collectd;
+
+import com.codahale.metrics.MetricRegistry;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+public class CollectdReporterSecurityTest {
+
+    private final MetricRegistry registry = new MetricRegistry();
+
+    @Test
+    public void testUnableSetSecurityLevelToSignWithoutUsername() {
+        assertThatIllegalArgumentException().isThrownBy(()->
+                CollectdReporter.forRegistry(registry)
+                        .withHostName("eddie")
+                        .withSecurityLevel(SecurityLevel.SIGN)
+                        .withPassword("t1_g3r")
+                        .build(new Sender("localhost", 25826)))
+                .withMessage("username is required for securityLevel: SIGN");
+    }
+
+    @Test
+    public void testUnableSetSecurityLevelToSignWithoutPassword() {
+        assertThatIllegalArgumentException().isThrownBy(()->
+                CollectdReporter.forRegistry(registry)
+                        .withHostName("eddie")
+                        .withSecurityLevel(SecurityLevel.SIGN)
+                        .withUsername("scott")
+                        .build(new Sender("localhost", 25826)))
+                .withMessage("password is required for securityLevel: SIGN");
+    }
+}
diff --git a/metrics-collectd/src/test/java/com/codahale/metrics/collectd/CollectdReporterTest.java b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/CollectdReporterTest.java
new file mode 100644
index 0000000..ae78e47
--- /dev/null
+++ b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/CollectdReporterTest.java
@@ -0,0 +1,301 @@
+package com.codahale.metrics.collectd;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricAttribute;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
+import org.collectd.api.ValueList;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class CollectdReporterTest {
+
+    @ClassRule
+    public static Receiver receiver = new Receiver(25826);
+
+    private final MetricRegistry registry = new MetricRegistry();
+    private CollectdReporter reporter;
+
+    @Before
+    public void setUp() {
+        reporter = CollectdReporter.forRegistry(registry)
+                .withHostName("eddie")
+                .build(new Sender("localhost", 25826));
+    }
+
+    @Test
+    public void reportsByteGauges() throws Exception {
+        reportsGauges((byte) 128);
+    }
+
+    @Test
+    public void reportsShortGauges() throws Exception {
+        reportsGauges((short) 2048);
+    }
+
+    @Test
+    public void reportsIntegerGauges() throws Exception {
+        reportsGauges(42);
+    }
+
+    @Test
+    public void reportsLongGauges() throws Exception {
+        reportsGauges(Long.MAX_VALUE);
+    }
+
+    @Test
+    public void reportsFloatGauges() throws Exception {
+        reportsGauges(0.25);
+    }
+
+    @Test
+    public void reportsDoubleGauges() throws Exception {
+        reportsGauges(0.125d);
+    }
+
+    private <T extends Number> void reportsGauges(T value) throws Exception {
+        reporter.report(
+                map("gauge", () -> value),
+                map(),
+                map(),
+                map(),
+                map());
+
+        assertThat(nextValues(receiver)).containsExactly(value.doubleValue());
+    }
+
+    @Test
+    public void reportsBooleanGauges() throws Exception {
+        reporter.report(
+                map("gauge", () -> true),
+                map(),
+                map(),
+                map(),
+                map());
+
+        assertThat(nextValues(receiver)).containsExactly(1d);
+
+        reporter.report(
+                map("gauge", () -> false),
+                map(),
+                map(),
+                map(),
+                map());
+
+        assertThat(nextValues(receiver)).containsExactly(0d);
+    }
+
+    @Test
+    public void doesNotReportStringGauges() throws Exception {
+        reporter.report(
+                map("unsupported", () -> "value"),
+                map(),
+                map(),
+                map(),
+                map());
+
+        assertThat(receiver.next()).isNull();
+    }
+
+    @Test
+    public void reportsCounters() throws Exception {
+        Counter counter = mock(Counter.class);
+        when(counter.getCount()).thenReturn(42L);
+
+        reporter.report(
+                map(),
+                map("api.rest.requests.count", counter),
+                map(),
+                map(),
+                map());
+
+        assertThat(nextValues(receiver)).containsExactly(42d);
+    }
+
+    @Test
+    public void reportsMeters() throws Exception {
+        Meter meter = mock(Meter.class);
+        when(meter.getCount()).thenReturn(1L);
+        when(meter.getOneMinuteRate()).thenReturn(2.0);
+        when(meter.getFiveMinuteRate()).thenReturn(3.0);
+        when(meter.getFifteenMinuteRate()).thenReturn(4.0);
+        when(meter.getMeanRate()).thenReturn(5.0);
+
+        reporter.report(
+                map(),
+                map(),
+                map(),
+                map("api.rest.requests", meter),
+                map());
+
+        assertThat(nextValues(receiver)).containsExactly(1d);
+        assertThat(nextValues(receiver)).containsExactly(2d);
+        assertThat(nextValues(receiver)).containsExactly(3d);
+        assertThat(nextValues(receiver)).containsExactly(4d);
+        assertThat(nextValues(receiver)).containsExactly(5d);
+    }
+
+    @Test
+    public void reportsHistograms() throws Exception {
+        Histogram histogram = mock(Histogram.class);
+        Snapshot snapshot = mock(Snapshot.class);
+        when(histogram.getCount()).thenReturn(1L);
+        when(histogram.getSnapshot()).thenReturn(snapshot);
+        when(snapshot.getMax()).thenReturn(2L);
+        when(snapshot.getMean()).thenReturn(3.0);
+        when(snapshot.getMin()).thenReturn(4L);
+        when(snapshot.getStdDev()).thenReturn(5.0);
+        when(snapshot.getMedian()).thenReturn(6.0);
+        when(snapshot.get75thPercentile()).thenReturn(7.0);
+        when(snapshot.get95thPercentile()).thenReturn(8.0);
+        when(snapshot.get98thPercentile()).thenReturn(9.0);
+        when(snapshot.get99thPercentile()).thenReturn(10.0);
+        when(snapshot.get999thPercentile()).thenReturn(11.0);
+
+        reporter.report(
+                map(),
+                map(),
+                map("histogram", histogram),
+                map(),
+                map());
+
+        for (int i = 1; i <= 11; i++) {
+            assertThat(nextValues(receiver)).containsExactly((double) i);
+        }
+    }
+
+    @Test
+    public void reportsTimers() throws Exception {
+        Timer timer = mock(Timer.class);
+        Snapshot snapshot = mock(Snapshot.class);
+        when(timer.getSnapshot()).thenReturn(snapshot);
+        when(timer.getCount()).thenReturn(1L);
+        when(timer.getSnapshot()).thenReturn(snapshot);
+        when(snapshot.getMax()).thenReturn(MILLISECONDS.toNanos(100));
+        when(snapshot.getMean()).thenReturn((double) MILLISECONDS.toNanos(200));
+        when(snapshot.getMin()).thenReturn(MILLISECONDS.toNanos(300));
+        when(snapshot.getStdDev()).thenReturn((double) MILLISECONDS.toNanos(400));
+        when(snapshot.getMedian()).thenReturn((double) MILLISECONDS.toNanos(500));
+        when(snapshot.get75thPercentile()).thenReturn((double) MILLISECONDS.toNanos(600));
+        when(snapshot.get95thPercentile()).thenReturn((double) MILLISECONDS.toNanos(700));
+        when(snapshot.get98thPercentile()).thenReturn((double) MILLISECONDS.toNanos(800));
+        when(snapshot.get99thPercentile()).thenReturn((double) MILLISECONDS.toNanos(900));
+        when(snapshot.get999thPercentile()).thenReturn((double) MILLISECONDS.toNanos(1000));
+        when(timer.getOneMinuteRate()).thenReturn(11.0);
+        when(timer.getFiveMinuteRate()).thenReturn(12.0);
+        when(timer.getFifteenMinuteRate()).thenReturn(13.0);
+        when(timer.getMeanRate()).thenReturn(14.0);
+
+        reporter.report(
+                map(),
+                map(),
+                map(),
+                map(),
+                map("timer", timer));
+
+        assertThat(nextValues(receiver)).containsExactly(1d);
+        assertThat(nextValues(receiver)).containsExactly(100d);
+        assertThat(nextValues(receiver)).containsExactly(200d);
+        assertThat(nextValues(receiver)).containsExactly(300d);
+        assertThat(nextValues(receiver)).containsExactly(400d);
+        assertThat(nextValues(receiver)).containsExactly(500d);
+        assertThat(nextValues(receiver)).containsExactly(600d);
+        assertThat(nextValues(receiver)).containsExactly(700d);
+        assertThat(nextValues(receiver)).containsExactly(800d);
+        assertThat(nextValues(receiver)).containsExactly(900d);
+        assertThat(nextValues(receiver)).containsExactly(1000d);
+        assertThat(nextValues(receiver)).containsExactly(11d);
+        assertThat(nextValues(receiver)).containsExactly(12d);
+        assertThat(nextValues(receiver)).containsExactly(13d);
+        assertThat(nextValues(receiver)).containsExactly(14d);
+    }
+
+    @Test
+    public void doesNotReportDisabledMetricAttributes() throws Exception {
+        final Meter meter = mock(Meter.class);
+        when(meter.getCount()).thenReturn(1L);
+        when(meter.getOneMinuteRate()).thenReturn(2.0);
+        when(meter.getFiveMinuteRate()).thenReturn(3.0);
+        when(meter.getFifteenMinuteRate()).thenReturn(4.0);
+        when(meter.getMeanRate()).thenReturn(5.0);
+
+        final Counter counter = mock(Counter.class);
+        when(counter.getCount()).thenReturn(11L);
+
+        CollectdReporter reporter = CollectdReporter.forRegistry(registry)
+                .withHostName("eddie")
+                .disabledMetricAttributes(EnumSet.of(MetricAttribute.M5_RATE, MetricAttribute.M15_RATE))
+                .build(new Sender("localhost", 25826));
+
+        reporter.report(
+                map(),
+                map("counter", counter),
+                map(),
+                map("meter", meter),
+                map());
+
+        assertThat(nextValues(receiver)).containsExactly(11d);
+        assertThat(nextValues(receiver)).containsExactly(1d);
+        assertThat(nextValues(receiver)).containsExactly(2d);
+        assertThat(nextValues(receiver)).containsExactly(5d);
+    }
+
+    @Test
+    public void sanitizesMetricName() throws Exception {
+        Counter counter = registry.counter("dash-illegal.slash/illegal");
+        counter.inc();
+
+        reporter.report();
+
+        ValueList values = receiver.next();
+        assertThat(values.getPlugin()).isEqualTo("dash_illegal.slash_illegal");
+    }
+
+    @Test
+    public void sanitizesMetricNameWithCustomMaxLength() throws Exception {
+        CollectdReporter customReporter = CollectdReporter.forRegistry(registry)
+                .withHostName("eddie")
+                .withMaxLength(20)
+                .build(new Sender("localhost", 25826));
+
+        Counter counter = registry.counter("dash-illegal.slash/illegal");
+        counter.inc();
+
+        customReporter.report();
+
+        ValueList values = receiver.next();
+        assertThat(values.getPlugin()).isEqualTo("dash_illegal.slash_i");
+    }
+
+    private <T> SortedMap<String, T> map() {
+        return Collections.emptySortedMap();
+    }
+
+    private <T> SortedMap<String, T> map(String name, T metric) {
+        final Map<String, T> map = Collections.singletonMap(name, metric);
+        return new TreeMap<>(map);
+    }
+
+    private List<Number> nextValues(Receiver receiver) throws Exception {
+        final ValueList valueList = receiver.next();
+        return valueList == null ? Collections.emptyList() : valueList.getValues();
+    }
+}
+
+
diff --git a/metrics-collectd/src/test/java/com/codahale/metrics/collectd/PacketWriterTest.java b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/PacketWriterTest.java
new file mode 100644
index 0000000..ecb94ad
--- /dev/null
+++ b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/PacketWriterTest.java
@@ -0,0 +1,194 @@
+package com.codahale.metrics.collectd;
+
+import org.junit.Test;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.data.Offset.offset;
+
+public class PacketWriterTest {
+
+    private MetaData metaData = new MetaData.Builder("nw-1.alpine.example.com", 1520961345L, 100)
+            .type("gauge")
+            .typeInstance("value")
+            .get();
+    private String username = "scott";
+    private String password = "t1_g$r";
+
+    @Test
+    public void testSignRequest() throws Exception {
+        AtomicBoolean packetVerified = new AtomicBoolean();
+        Sender sender = new Sender("localhost", 4009) {
+            @Override
+            public void send(ByteBuffer buffer) throws IOException {
+                short type = buffer.getShort();
+                assertThat(type).isEqualTo((short) 512);
+                short length = buffer.getShort();
+                assertThat(length).isEqualTo((short) 41);
+                byte[] packetSignature = new byte[32];
+                buffer.get(packetSignature, 0, 32);
+
+                byte[] packetUsername = new byte[length - 36];
+                buffer.get(packetUsername, 0, packetUsername.length);
+                assertThat(new String(packetUsername, UTF_8)).isEqualTo(username);
+
+                byte[] packet = new byte[buffer.remaining()];
+                buffer.get(packet);
+
+                byte[] usernameAndPacket = new byte[username.length() + packet.length];
+                System.arraycopy(packetUsername, 0, usernameAndPacket, 0, packetUsername.length);
+                System.arraycopy(packet, 0, usernameAndPacket, packetUsername.length, packet.length);
+                assertThat(sign(usernameAndPacket, password)).isEqualTo(packetSignature);
+
+                verifyPacket(packet);
+                packetVerified.set(true);
+            }
+
+            private byte[] sign(byte[] input, String password) {
+                Mac mac;
+                try {
+                    mac = Mac.getInstance("HmacSHA256");
+                    mac.init(new SecretKeySpec(password.getBytes(UTF_8), "HmacSHA256"));
+                } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+                    throw new RuntimeException(e);
+                }
+                return mac.doFinal(input);
+            }
+
+        };
+        PacketWriter packetWriter = new PacketWriter(sender, username, password, SecurityLevel.SIGN);
+        packetWriter.write(metaData, 42);
+        assertThat(packetVerified).isTrue();
+    }
+
+    @Test
+    public void testEncryptRequest() throws Exception {
+        AtomicBoolean packetVerified = new AtomicBoolean();
+        Sender sender = new Sender("localhost", 4009) {
+            @Override
+            public void send(ByteBuffer buffer) throws IOException {
+                short type = buffer.getShort();
+                assertThat(type).isEqualTo((short) 0x0210);
+                short length = buffer.getShort();
+                assertThat(length).isEqualTo((short) 134);
+                short usernameLength = buffer.getShort();
+                assertThat(usernameLength).isEqualTo((short) 5);
+                byte[] packetUsername = new byte[usernameLength];
+                buffer.get(packetUsername, 0, packetUsername.length);
+                assertThat(new String(packetUsername, UTF_8)).isEqualTo(username);
+
+                byte[] iv = new byte[16];
+                buffer.get(iv, 0, iv.length);
+                byte[] encryptedPacket = new byte[buffer.remaining()];
+                buffer.get(encryptedPacket);
+
+                byte[] decryptedPacket = decrypt(iv, encryptedPacket);
+                byte[] hash = new byte[20];
+                System.arraycopy(decryptedPacket, 0, hash, 0, 20);
+                byte[] rawData = new byte[decryptedPacket.length - 20];
+                System.arraycopy(decryptedPacket, 20, rawData, 0, decryptedPacket.length - 20);
+                assertThat(sha1(rawData)).isEqualTo(hash);
+
+                verifyPacket(rawData);
+                packetVerified.set(true);
+            }
+
+            private byte[] decrypt(byte[] iv, byte[] input) {
+                try {
+                    Cipher cipher = Cipher.getInstance("AES_256/OFB/NoPadding");
+                    cipher.init(Cipher.DECRYPT_MODE,
+                            new SecretKeySpec(sha256(password.getBytes(UTF_8)), "AES"),
+                            new IvParameterSpec(iv));
+                    return cipher.doFinal(input);
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+            }
+
+            private byte[] sha256(byte[] input) {
+                try {
+                    return MessageDigest.getInstance("SHA-256").digest(input);
+                } catch (NoSuchAlgorithmException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+
+            private byte[] sha1(byte[] input) {
+                try {
+                    return MessageDigest.getInstance("SHA-1").digest(input);
+                } catch (NoSuchAlgorithmException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        };
+        PacketWriter packetWriter = new PacketWriter(sender, username, password, SecurityLevel.ENCRYPT);
+        packetWriter.write(metaData, 42);
+        assertThat(packetVerified).isTrue();
+    }
+
+    private void verifyPacket(byte[] packetArr) {
+        ByteBuffer packet = ByteBuffer.wrap(packetArr);
+
+        short hostType = packet.getShort();
+        assertThat(hostType).isEqualTo((short) 0);
+        short hostLength = packet.getShort();
+        assertThat(hostLength).isEqualTo((short) 28);
+        byte[] host = new byte[hostLength - 5];
+        packet.get(host, 0, host.length);
+        assertThat(new String(host, UTF_8)).isEqualTo("nw-1.alpine.example.com");
+        assertThat(packet.get()).isEqualTo((byte) 0);
+
+        short timestampType = packet.getShort();
+        assertThat(timestampType).isEqualTo((short) 1);
+        short timestampLength = packet.getShort();
+        assertThat(timestampLength).isEqualTo((short) 12);
+        assertThat(packet.getLong()).isEqualTo(1520961345L);
+
+        short typeType = packet.getShort();
+        assertThat(typeType).isEqualTo((short) 4);
+        short typeLength = packet.getShort();
+        assertThat(typeLength).isEqualTo((short) 10);
+        byte[] type = new byte[typeLength - 5];
+        packet.get(type, 0, type.length);
+        assertThat(new String(type, UTF_8)).isEqualTo("gauge");
+        assertThat(packet.get()).isEqualTo((byte) 0);
+
+        short typeInstanceType = packet.getShort();
+        assertThat(typeInstanceType).isEqualTo((short) 5);
+        short typeInstanceLength = packet.getShort();
+        assertThat(typeInstanceLength).isEqualTo((short) 10);
+        byte[] typeInstance = new byte[typeInstanceLength - 5];
+        packet.get(typeInstance, 0, typeInstance.length);
+        assertThat(new String(typeInstance, UTF_8)).isEqualTo("value");
+        assertThat(packet.get()).isEqualTo((byte) 0);
+
+        short periodType = packet.getShort();
+        assertThat(periodType).isEqualTo((short) 7);
+        short periodLength = packet.getShort();
+        assertThat(periodLength).isEqualTo((short) 12);
+        assertThat(packet.getLong()).isEqualTo(100);
+
+        short valuesType = packet.getShort();
+        assertThat(valuesType).isEqualTo((short) 6);
+        short valuesLength = packet.getShort();
+        assertThat(valuesLength).isEqualTo((short) 15);
+        short amountOfValues = packet.getShort();
+        assertThat(amountOfValues).isEqualTo((short) 1);
+        byte dataType = packet.get();
+        assertThat(dataType).isEqualTo((byte) 1);
+        assertThat(packet.order(ByteOrder.LITTLE_ENDIAN).getDouble()).isEqualTo(42.0, offset(0.01));
+    }
+
+}
\ No newline at end of file
diff --git a/metrics-collectd/src/test/java/com/codahale/metrics/collectd/Receiver.java b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/Receiver.java
new file mode 100644
index 0000000..396ec7f
--- /dev/null
+++ b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/Receiver.java
@@ -0,0 +1,63 @@
+package com.codahale.metrics.collectd;
+
+import org.collectd.api.Notification;
+import org.collectd.api.ValueList;
+import org.collectd.protocol.Dispatcher;
+import org.collectd.protocol.UdpReceiver;
+import org.junit.rules.ExternalResource;
+
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+public final class Receiver extends ExternalResource {
+
+    private final int port;
+
+    private UdpReceiver receiver;
+    private DatagramSocket socket;
+    private BlockingQueue<ValueList> queue = new LinkedBlockingQueue<>();
+
+    public Receiver(int port) {
+        this.port = port;
+    }
+
+    @Override
+    protected void before() throws Throwable {
+        socket = new DatagramSocket(null);
+        socket.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), port));
+
+        receiver = new UdpReceiver(new Dispatcher() {
+            @Override
+            public void dispatch(ValueList values) {
+                queue.offer(new ValueList(values));
+            }
+
+            @Override
+            public void dispatch(Notification notification) {
+                throw new UnsupportedOperationException();
+            }
+        });
+        receiver.setPort(port);
+        new Thread(() -> {
+            try {
+                receiver.listen(socket);
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+        }).start();
+    }
+
+    public ValueList next() throws InterruptedException {
+        return queue.poll(2, TimeUnit.SECONDS);
+    }
+
+    @Override
+    protected void after() {
+        receiver.shutdown();
+        socket.close();
+    }
+}
diff --git a/metrics-collectd/src/test/java/com/codahale/metrics/collectd/SanitizeTest.java b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/SanitizeTest.java
new file mode 100644
index 0000000..bf713ca
--- /dev/null
+++ b/metrics-collectd/src/test/java/com/codahale/metrics/collectd/SanitizeTest.java
@@ -0,0 +1,39 @@
+package com.codahale.metrics.collectd;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class SanitizeTest {
+
+    private Sanitize sanitize = new Sanitize(Sanitize.DEFAULT_MAX_LENGTH);
+
+    @Test
+    public void replacesIllegalCharactersInName() throws Exception {
+        assertThat(sanitize.name("foo\u0000bar/baz-quux")).isEqualTo("foo_bar_baz_quux");
+    }
+
+    @Test
+    public void replacesIllegalCharactersInInstanceName() throws Exception {
+        assertThat(sanitize.instanceName("foo\u0000bar/baz-quux")).isEqualTo("foo_bar_baz-quux");
+    }
+
+    @Test
+    public void truncatesNamesExceedingMaxLength() throws Exception {
+        String longName = "01234567890123456789012345678901234567890123456789012345678901234567890123456789";
+        assertThat(sanitize.name(longName)).isEqualTo(longName.substring(0, (Sanitize.DEFAULT_MAX_LENGTH)));
+    }
+
+    @Test
+    public void truncatesNamesExceedingCustomMaxLength() throws Exception {
+        Sanitize customSanitize = new Sanitize(70);
+        String longName = "01234567890123456789012345678901234567890123456789012345678901234567890123456789";
+        assertThat(customSanitize.name(longName)).isEqualTo(longName.substring(0, 70));
+    }
+
+    @Test
+    public void replacesNonASCIICharacters() throws Exception {
+        assertThat(sanitize.name("M" + '\u00FC' + "nchen")).isEqualTo("M_nchen");
+    }
+
+}
diff --git a/metrics-core/pom.xml b/metrics-core/pom.xml
index 25635ad..db6d3a9 100644
--- a/metrics-core/pom.xml
+++ b/metrics-core/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-core</artifactId>
@@ -16,4 +16,55 @@
         production. Metrics provides a powerful toolkit of ways to measure the behavior of critical
         components in your production environment.
     </description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics</javaModuleName>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+    <dependencies>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+            <version>${commons-lang3.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
 </project>
diff --git a/metrics-core/src/main/java/com/codahale/metrics/CachedGauge.java b/metrics-core/src/main/java/com/codahale/metrics/CachedGauge.java
index 565e34e..569a58e 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/CachedGauge.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/CachedGauge.java
@@ -2,24 +2,24 @@ package com.codahale.metrics;
 
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * A {@link Gauge} implementation which caches its value for a period of time.
  *
- * @param <T>    the type of the gauge's value
+ * @param <T> the type of the gauge's value
  */
 public abstract class CachedGauge<T> implements Gauge<T> {
     private final Clock clock;
     private final AtomicLong reloadAt;
     private final long timeoutNS;
-
-    private volatile T value;
+    private final AtomicReference<T> value;
 
     /**
      * Creates a new cached gauge with the given timeout period.
      *
-     * @param timeout        the timeout
-     * @param timeoutUnit    the unit of {@code timeout}
+     * @param timeout     the timeout
+     * @param timeoutUnit the unit of {@code timeout}
      */
     protected CachedGauge(long timeout, TimeUnit timeoutUnit) {
         this(Clock.defaultClock(), timeout, timeoutUnit);
@@ -28,14 +28,15 @@ public abstract class CachedGauge<T> implements Gauge<T> {
     /**
      * Creates a new cached gauge with the given clock and timeout period.
      *
-     * @param clock          the clock used to calculate the timeout
-     * @param timeout        the timeout
-     * @param timeoutUnit    the unit of {@code timeout}
+     * @param clock       the clock used to calculate the timeout
+     * @param timeout     the timeout
+     * @param timeoutUnit the unit of {@code timeout}
      */
     protected CachedGauge(Clock clock, long timeout, TimeUnit timeoutUnit) {
         this.clock = clock;
-        this.reloadAt = new AtomicLong(0);
+        this.reloadAt = new AtomicLong(clock.getTick());
         this.timeoutNS = timeoutUnit.toNanos(timeout);
+        this.value = new AtomicReference<>();
     }
 
     /**
@@ -47,14 +48,19 @@ public abstract class CachedGauge<T> implements Gauge<T> {
 
     @Override
     public T getValue() {
-        if (shouldLoad()) {
-            this.value = loadValue();
+        T currentValue = this.value.get();
+        if (shouldLoad() || currentValue == null) {
+            T newValue = loadValue();
+            if (!this.value.compareAndSet(currentValue, newValue)) {
+                return this.value.get();
+            }
+            return newValue;
         }
-        return value;
+        return currentValue;
     }
 
     private boolean shouldLoad() {
-        for (; ; ) {
+        for ( ;; ) {
             final long time = clock.getTick();
             final long current = reloadAt.get();
             if (current > time) {
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ChunkedAssociativeLongArray.java b/metrics-core/src/main/java/com/codahale/metrics/ChunkedAssociativeLongArray.java
index 8caf4e0..5acde4a 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/ChunkedAssociativeLongArray.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/ChunkedAssociativeLongArray.java
@@ -1,13 +1,12 @@
 package com.codahale.metrics;
 
-import static java.lang.System.arraycopy;
-import static java.util.Arrays.binarySearch;
-
 import java.lang.ref.SoftReference;
 import java.util.ArrayDeque;
+import java.util.Deque;
 import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.ListIterator;
+
+import static java.lang.System.arraycopy;
+import static java.util.Arrays.binarySearch;
 
 class ChunkedAssociativeLongArray {
     private static final long[] EMPTY = new long[0];
@@ -15,25 +14,16 @@ class ChunkedAssociativeLongArray {
     private static final int MAX_CACHE_SIZE = 128;
 
     private final int defaultChunkSize;
+
     /*
      * We use this ArrayDeque as cache to store chunks that are expired and removed from main data structure.
      * Then instead of allocating new Chunk immediately we are trying to poll one from this deque.
      * So if you have constant or slowly changing load ChunkedAssociativeLongArray will never
      * throw away old chunks or allocate new ones which makes this data structure almost garbage free.
      */
-    private final ArrayDeque<SoftReference<Chunk>> chunksCache = new ArrayDeque<SoftReference<Chunk>>();
+    private final ArrayDeque<SoftReference<Chunk>> chunksCache = new ArrayDeque<>();
 
-    /*
-     * Why LinkedList if we are creating fast data structure with low GC overhead?
-     *
-     * First of all LinkedList here has relatively small size countOfStoredMeasurements / DEFAULT_CHUNK_SIZE.
-     * And we are heavily rely on LinkedList implementation because:
-     * 1. Now we deleting chunks from both sides of the list  in trim(long startKey, long endKey)
-     * 2. Deleting from and inserting chunks into the middle in clear(long startKey, long endKey)
-     *
-     * LinkedList gives us O(1) complexity for all this operations and that is not the case with ArrayList.
-     */
-    private final LinkedList<Chunk> chunks = new LinkedList<Chunk>();
+    private final Deque<Chunk> chunks = new ArrayDeque<>();
 
     ChunkedAssociativeLongArray() {
         this(DEFAULT_CHUNK_SIZE);
@@ -61,44 +51,37 @@ class ChunkedAssociativeLongArray {
 
     private void freeChunk(Chunk chunk) {
         if (chunksCache.size() < MAX_CACHE_SIZE) {
-            chunksCache.add(new SoftReference<Chunk>(chunk));
+            chunksCache.add(new SoftReference<>(chunk));
         }
     }
 
     synchronized boolean put(long key, long value) {
         Chunk activeChunk = chunks.peekLast();
-
-        if (activeChunk == null) { // lazy chunk creation
+        if (activeChunk != null && activeChunk.cursor != 0 && activeChunk.keys[activeChunk.cursor - 1] > key) {
+            // key should be the same as last inserted or bigger
+            return false;
+        }
+        if (activeChunk == null || activeChunk.cursor - activeChunk.startIndex == activeChunk.chunkSize) {
+            // The last chunk doesn't exist or full
             activeChunk = allocateChunk();
             chunks.add(activeChunk);
-
-        } else {
-            if (activeChunk.cursor != 0 && activeChunk.keys[activeChunk.cursor - 1] > key) {
-                return false; // key should be the same as last inserted or bigger
-            }
-            boolean isFull = activeChunk.cursor - activeChunk.startIndex == activeChunk.chunkSize;
-            if (isFull) {
-                activeChunk = allocateChunk();
-                chunks.add(activeChunk);
-            }
         }
-
         activeChunk.append(key, value);
         return true;
     }
 
     synchronized long[] values() {
-        int valuesSize = size();
+        final int valuesSize = size();
         if (valuesSize == 0) {
             return EMPTY;
         }
 
-        long[] values = new long[valuesSize];
+        final long[] values = new long[valuesSize];
         int valuesIndex = 0;
-        for (Chunk copySourceChunk : chunks) {
-            int length = copySourceChunk.cursor - copySourceChunk.startIndex;
+        for (Chunk chunk : chunks) {
+            int length = chunk.cursor - chunk.startIndex;
             int itemsToCopy = Math.min(valuesSize - valuesIndex, length);
-            arraycopy(copySourceChunk.values, copySourceChunk.startIndex, values, valuesIndex, itemsToCopy);
+            arraycopy(chunk.values, chunk.startIndex, values, valuesIndex, itemsToCopy);
             valuesIndex += length;
         }
         return values;
@@ -113,18 +96,17 @@ class ChunkedAssociativeLongArray {
     }
 
     synchronized String out() {
-        Iterator<Chunk> fromTailIterator = chunks.iterator();
-        StringBuilder builder = new StringBuilder();
-        while (fromTailIterator.hasNext()) {
-            Chunk copySourceChunk = fromTailIterator.next();
+        final StringBuilder builder = new StringBuilder();
+        final Iterator<Chunk> iterator = chunks.iterator();
+        while (iterator.hasNext()) {
+            final Chunk chunk = iterator.next();
             builder.append('[');
-            for (int i = copySourceChunk.startIndex; i < copySourceChunk.cursor; i++) {
-                long key = copySourceChunk.keys[i];
-                long value = copySourceChunk.values[i];
-                builder.append('(').append(key).append(": ").append(value).append(')').append(' ');
+            for (int i = chunk.startIndex; i < chunk.cursor; i++) {
+                builder.append('(').append(chunk.keys[i]).append(": ")
+                        .append(chunk.values[i]).append(')').append(' ');
             }
             builder.append(']');
-            if (fromTailIterator.hasNext()) {
+            if (iterator.hasNext()) {
                 builder.append("->");
             }
         }
@@ -133,10 +115,9 @@ class ChunkedAssociativeLongArray {
 
     /**
      * Try to trim all beyond specified boundaries.
-     * All items that are less then startKey or greater/equals then endKey
      *
-     * @param startKey
-     * @param endKey
+     * @param startKey the start value for which all elements less than it should be removed.
+     * @param endKey   the end value for which all elements greater/equals than it should be removed.
      */
     synchronized void trim(long startKey, long endKey) {
         /*
@@ -144,83 +125,33 @@ class ChunkedAssociativeLongArray {
          *       |5______________________________23|                    :: trim(5, 23)
          *       [5, 9] -> [10, 13, 14, 15] -> [21]                     :: result layout
          */
-        ListIterator<Chunk> fromHeadIterator = chunks.listIterator(chunks.size());
-        while (fromHeadIterator.hasPrevious()) {
-            Chunk currentHead = fromHeadIterator.previous();
-            if (isFirstElementIsEmptyOrGreaterEqualThanKey(currentHead, endKey)) {
-                freeChunk(currentHead);
-                fromHeadIterator.remove();
-            } else {
-                int newEndIndex = findFirstIndexOfGreaterEqualElements(
-                    currentHead.keys, currentHead.startIndex, currentHead.cursor, endKey
-                );
-                currentHead.cursor = newEndIndex;
-                break;
-            }
-        }
-
-        ListIterator<Chunk> fromTailIterator = chunks.listIterator();
-        while (fromTailIterator.hasNext()) {
-            Chunk currentTail = fromTailIterator.next();
-            if (isLastElementIsLessThanKey(currentTail, startKey)) {
+        final Iterator<Chunk> descendingIterator = chunks.descendingIterator();
+        while (descendingIterator.hasNext()) {
+            final Chunk currentTail = descendingIterator.next();
+            if (isFirstElementIsEmptyOrGreaterEqualThanKey(currentTail, endKey)) {
                 freeChunk(currentTail);
-                fromTailIterator.remove();
+                descendingIterator.remove();
             } else {
-                int newStartIndex = findFirstIndexOfGreaterEqualElements(
-                    currentTail.keys, currentTail.startIndex, currentTail.cursor, startKey
-                );
-                if (currentTail.startIndex != newStartIndex) {
-                    currentTail.startIndex = newStartIndex;
-                    currentTail.chunkSize = currentTail.cursor - currentTail.startIndex;
-                }
+                currentTail.cursor = findFirstIndexOfGreaterEqualElements(currentTail.keys, currentTail.startIndex,
+                        currentTail.cursor, endKey);
                 break;
             }
         }
-    }
-
-    /**
-     * Clear all in specified boundaries.
-     * Remove all items between startKey(inclusive) and endKey(exclusive)
-     *
-     * @param startKey
-     * @param endKey
-     */
-    synchronized void clear(long startKey, long endKey) {
-        /*
-         * [3, 4, 5, 9] -> [10, 13, 14, 15] -> [21, 24, 29, 30] -> [31] :: start layout
-         *       |5______________________________23|                    :: clear(5, 23)
-         * [3, 4]               ->                 [24, 29, 30] -> [31] :: result layout
-         */
-        ListIterator<Chunk> fromHeadIterator = chunks.listIterator(chunks.size());
-        while (fromHeadIterator.hasPrevious()) {
-            Chunk currentTail = fromHeadIterator.previous();
-            if (!isFirstElementIsEmptyOrGreaterEqualThanKey(currentTail, endKey)) {
-                Chunk afterTailChunk = splitChunkOnTwoSeparateChunks(currentTail, endKey);
-                if (afterTailChunk != null) {
-                    fromHeadIterator.add(afterTailChunk);
-                    break;
-                }
-            }
-        }
 
-        // now we should remove specified gap [startKey, endKey]
-        while (fromHeadIterator.hasPrevious()) {
-            Chunk afterGapHead = fromHeadIterator.previous();
-            if (isFirstElementIsEmptyOrGreaterEqualThanKey(afterGapHead, startKey)) {
-                freeChunk(afterGapHead);
-                fromHeadIterator.remove();
+        final Iterator<Chunk> iterator = chunks.iterator();
+        while (iterator.hasNext()) {
+            final Chunk currentHead = iterator.next();
+            if (isLastElementIsLessThanKey(currentHead, startKey)) {
+                freeChunk(currentHead);
+                iterator.remove();
             } else {
-                int newEndIndex = findFirstIndexOfGreaterEqualElements(
-                    afterGapHead.keys, afterGapHead.startIndex, afterGapHead.cursor, startKey
-                );
-                if (newEndIndex == afterGapHead.startIndex) {
-                    break;
-                }
-                if (afterGapHead.cursor != newEndIndex) {
-                    afterGapHead.cursor = newEndIndex;
-                    afterGapHead.chunkSize = afterGapHead.cursor - afterGapHead.startIndex;
-                    break;
+                final int newStartIndex = findFirstIndexOfGreaterEqualElements(currentHead.keys, currentHead.startIndex,
+                        currentHead.cursor, startKey);
+                if (currentHead.startIndex != newStartIndex) {
+                    currentHead.startIndex = newStartIndex;
+                    currentHead.chunkSize = currentHead.cursor - currentHead.startIndex;
                 }
+                break;
             }
         }
     }
@@ -229,52 +160,20 @@ class ChunkedAssociativeLongArray {
         chunks.clear();
     }
 
-    private Chunk splitChunkOnTwoSeparateChunks(Chunk chunk, long key) {
-        /*
-         * [1, 2, 3, 4, 5, 6, 7, 8] :: beforeSplit
-         * |s--------chunk-------e|
-         *
-         *  splitChunkOnTwoSeparateChunks(chunk, 5)
-         *
-         * [1, 2, 3, 4, 5, 6, 7, 8] :: afterSplit
-         * |s--tail--e||s--head--e|
-         */
-        int splitIndex = findFirstIndexOfGreaterEqualElements(
-            chunk.keys, chunk.startIndex, chunk.cursor, key
-        );
-        if (splitIndex == chunk.startIndex || splitIndex == chunk.cursor) {
-            return null;
-        }
-        int newTailSize = splitIndex - chunk.startIndex;
-        Chunk newTail = new Chunk(chunk.keys, chunk.values, chunk.startIndex, splitIndex, newTailSize);
-        chunk.startIndex = splitIndex;
-        chunk.chunkSize = chunk.chunkSize - newTailSize;
-        return newTail;
-    }
-
     private boolean isFirstElementIsEmptyOrGreaterEqualThanKey(Chunk chunk, long key) {
-        return chunk.cursor == chunk.startIndex
-            || chunk.keys[chunk.startIndex] >= key;
+        return chunk.cursor == chunk.startIndex || chunk.keys[chunk.startIndex] >= key;
     }
 
     private boolean isLastElementIsLessThanKey(Chunk chunk, long key) {
-        return chunk.cursor == chunk.startIndex
-            || chunk.keys[chunk.cursor - 1] < key;
+        return chunk.cursor == chunk.startIndex || chunk.keys[chunk.cursor - 1] < key;
     }
 
-
     private int findFirstIndexOfGreaterEqualElements(long[] array, int startIndex, int endIndex, long minKey) {
         if (endIndex == startIndex || array[startIndex] >= minKey) {
             return startIndex;
         }
-        int searchIndex = binarySearch(array, startIndex, endIndex, minKey);
-        int realIndex;
-        if (searchIndex < 0) {
-            realIndex = -(searchIndex + 1);
-        } else {
-            realIndex = searchIndex;
-        }
-        return realIndex;
+        final int keyIndex = binarySearch(array, startIndex, endIndex, minKey);
+        return keyIndex < 0 ? -(keyIndex + 1) : keyIndex;
     }
 
     private static class Chunk {
@@ -292,15 +191,6 @@ class ChunkedAssociativeLongArray {
             this.values = new long[chunkSize];
         }
 
-        private Chunk(final long[] keys, final long[] values,
-                      final int startIndex, final int cursor, final int chunkSize) {
-            this.keys = keys;
-            this.values = values;
-            this.startIndex = startIndex;
-            this.cursor = cursor;
-            this.chunkSize = chunkSize;
-        }
-
         private void append(long key, long value) {
             keys[cursor] = key;
             values[cursor] = value;
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Clock.java b/metrics-core/src/main/java/com/codahale/metrics/Clock.java
index 2fb7b1e..5486812 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Clock.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Clock.java
@@ -1,8 +1,5 @@
 package com.codahale.metrics;
 
-import java.lang.management.ManagementFactory;
-import java.lang.management.ThreadMXBean;
-
 /**
  * An abstraction for how time passes. It is passed to {@link Timer} to track timing.
  */
@@ -23,17 +20,14 @@ public abstract class Clock {
         return System.currentTimeMillis();
     }
 
-    private static final Clock DEFAULT = new UserTimeClock();
-
     /**
      * The default clock to use.
      *
      * @return the default {@link Clock} instance
-     *
      * @see Clock.UserTimeClock
      */
     public static Clock defaultClock() {
-        return DEFAULT;
+        return UserTimeClockHolder.DEFAULT;
     }
 
     /**
@@ -46,15 +40,7 @@ public abstract class Clock {
         }
     }
 
-    /**
-     * A clock implementation which returns the current thread's CPU time.
-     */
-    public static class CpuTimeClock extends Clock {
-        private static final ThreadMXBean THREAD_MX_BEAN = ManagementFactory.getThreadMXBean();
-
-        @Override
-        public long getTick() {
-            return THREAD_MX_BEAN.getCurrentThreadCpuTime();
-        }
+    private static class UserTimeClockHolder {
+        private static final Clock DEFAULT = new UserTimeClock();
     }
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ConsoleReporter.java b/metrics-core/src/main/java/com/codahale/metrics/ConsoleReporter.java
index e61095c..db559c8 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/ConsoleReporter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/ConsoleReporter.java
@@ -2,7 +2,13 @@ package com.codahale.metrics;
 
 import java.io.PrintStream;
 import java.text.DateFormat;
-import java.util.*;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TimeZone;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
@@ -174,16 +180,16 @@ public class ConsoleReporter extends ScheduledReporter {
          */
         public ConsoleReporter build() {
             return new ConsoleReporter(registry,
-                                       output,
-                                       locale,
-                                       clock,
-                                       timeZone,
-                                       rateUnit,
-                                       durationUnit,
-                                       filter,
-                                       executor,
-                                       shutdownExecutorOnStop,
-                                       disabledMetricAttributes);
+                    output,
+                    locale,
+                    clock,
+                    timeZone,
+                    rateUnit,
+                    durationUnit,
+                    filter,
+                    executor,
+                    shutdownExecutorOnStop,
+                    disabledMetricAttributes);
         }
     }
 
@@ -210,12 +216,13 @@ public class ConsoleReporter extends ScheduledReporter {
         this.locale = locale;
         this.clock = clock;
         this.dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT,
-                                                         DateFormat.MEDIUM,
-                                                         locale);
+                DateFormat.MEDIUM,
+                locale);
         dateFormat.setTimeZone(timeZone);
     }
 
     @Override
+    @SuppressWarnings("rawtypes")
     public void report(SortedMap<String, Gauge> gauges,
                        SortedMap<String, Counter> counters,
                        SortedMap<String, Histogram> histograms,
@@ -229,7 +236,7 @@ public class ConsoleReporter extends ScheduledReporter {
             printWithBanner("-- Gauges", '-');
             for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
                 output.println(entry.getKey());
-                printGauge(entry);
+                printGauge(entry.getValue());
             }
             output.println();
         }
@@ -286,8 +293,8 @@ public class ConsoleReporter extends ScheduledReporter {
         output.printf(locale, "             count = %d%n", entry.getValue().getCount());
     }
 
-    private void printGauge(Map.Entry<String, Gauge> entry) {
-        output.printf(locale, "             value = %s%n", entry.getValue().getValue());
+    private void printGauge(Gauge<?> gauge) {
+        output.printf(locale, "             value = %s%n", gauge.getValue());
     }
 
     private void printHistogram(Histogram histogram) {
@@ -336,11 +343,12 @@ public class ConsoleReporter extends ScheduledReporter {
 
     /**
      * Print only if the attribute is enabled
-     * @param type Metric attribute
+     *
+     * @param type   Metric attribute
      * @param status Status to be logged
      */
     private void printIfEnabled(MetricAttribute type, String status) {
-        if(getDisabledMetricAttributes().contains(type)) {
+        if (getDisabledMetricAttributes().contains(type)) {
             return;
         }
 
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Counter.java b/metrics-core/src/main/java/com/codahale/metrics/Counter.java
index 9aa6ba7..f956d03 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Counter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Counter.java
@@ -1,13 +1,15 @@
 package com.codahale.metrics;
 
+import java.util.concurrent.atomic.LongAdder;
+
 /**
  * An incrementing and decrementing counter metric.
  */
 public class Counter implements Metric, Counting {
-    private final LongAdderAdapter count;
+    private final LongAdder count;
 
     public Counter() {
-        this.count = LongAdderProxy.create();
+        this.count = new LongAdder();
     }
 
     /**
diff --git a/metrics-core/src/main/java/com/codahale/metrics/CsvReporter.java b/metrics-core/src/main/java/com/codahale/metrics/CsvReporter.java
index e3e6bc1..2b9d860 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/CsvReporter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/CsvReporter.java
@@ -3,18 +3,25 @@ package com.codahale.metrics;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.*;
-import java.nio.charset.Charset;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
 import java.util.Locale;
 import java.util.Map;
 import java.util.SortedMap;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 /**
  * A reporter which creates a comma-separated values file of the measurements for each metric.
  */
 public class CsvReporter extends ScheduledReporter {
+    private static final String DEFAULT_SEPARATOR = ",";
+
     /**
      * Returns a new {@link Builder} for {@link CsvReporter}.
      *
@@ -32,6 +39,7 @@ public class CsvReporter extends ScheduledReporter {
     public static class Builder {
         private final MetricRegistry registry;
         private Locale locale;
+        private String separator;
         private TimeUnit rateUnit;
         private TimeUnit durationUnit;
         private Clock clock;
@@ -43,6 +51,7 @@ public class CsvReporter extends ScheduledReporter {
         private Builder(MetricRegistry registry) {
             this.registry = registry;
             this.locale = Locale.getDefault();
+            this.separator = DEFAULT_SEPARATOR;
             this.rateUnit = TimeUnit.SECONDS;
             this.durationUnit = TimeUnit.MILLISECONDS;
             this.clock = Clock.defaultClock();
@@ -111,6 +120,17 @@ public class CsvReporter extends ScheduledReporter {
             return this;
         }
 
+        /**
+         * Use the given string to use as the separator for values.
+         *
+         * @param separator the string to use for the separator.
+         * @return {@code this}
+         */
+        public Builder withSeparator(String separator) {
+            this.separator = separator;
+            return this;
+        }
+
         /**
          * Use the given {@link Clock} instance for the time.
          *
@@ -147,29 +167,39 @@ public class CsvReporter extends ScheduledReporter {
          */
         public CsvReporter build(File directory) {
             return new CsvReporter(registry,
-                                   directory,
-                                   locale,
-                                   rateUnit,
-                                   durationUnit,
-                                   clock,
-                                   filter,
-                                   executor,
-                                   shutdownExecutorOnStop,
-                                   csvFileProvider);
+                    directory,
+                    locale,
+                    separator,
+                    rateUnit,
+                    durationUnit,
+                    clock,
+                    filter,
+                    executor,
+                    shutdownExecutorOnStop,
+                    csvFileProvider);
         }
     }
 
     private static final Logger LOGGER = LoggerFactory.getLogger(CsvReporter.class);
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
 
     private final File directory;
     private final Locale locale;
+    private final String separator;
     private final Clock clock;
     private final CsvFileProvider csvFileProvider;
 
+    private final String histogramFormat;
+    private final String meterFormat;
+    private final String timerFormat;
+
+    private final String timerHeader;
+    private final String meterHeader;
+    private final String histogramHeader;
+
     private CsvReporter(MetricRegistry registry,
                         File directory,
                         Locale locale,
+                        String separator,
                         TimeUnit rateUnit,
                         TimeUnit durationUnit,
                         Clock clock,
@@ -180,11 +210,21 @@ public class CsvReporter extends ScheduledReporter {
         super(registry, "csv-reporter", filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop);
         this.directory = directory;
         this.locale = locale;
+        this.separator = separator;
         this.clock = clock;
         this.csvFileProvider = csvFileProvider;
+
+        this.histogramFormat = String.join(separator, "%d", "%d", "%f", "%d", "%f", "%f", "%f", "%f", "%f", "%f", "%f");
+        this.meterFormat = String.join(separator, "%d", "%f", "%f", "%f", "%f", "events/%s");
+        this.timerFormat = String.join(separator, "%d", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "%f", "calls/%s", "%s");
+
+        this.timerHeader = String.join(separator, "count", "max", "mean", "min", "stddev", "p50", "p75", "p95", "p98", "p99", "p999", "mean_rate", "m1_rate", "m5_rate", "m15_rate", "rate_unit", "duration_unit");
+        this.meterHeader = String.join(separator, "count", "mean_rate", "m1_rate", "m5_rate", "m15_rate", "rate_unit");
+        this.histogramHeader = String.join(separator, "count", "max", "mean", "min", "stddev", "p50", "p75", "p95", "p98", "p99", "p999");
     }
 
     @Override
+    @SuppressWarnings("rawtypes")
     public void report(SortedMap<String, Gauge> gauges,
                        SortedMap<String, Counter> counters,
                        SortedMap<String, Histogram> histograms,
@@ -217,66 +257,66 @@ public class CsvReporter extends ScheduledReporter {
         final Snapshot snapshot = timer.getSnapshot();
 
         report(timestamp,
-               name,
-               "count,max,mean,min,stddev,p50,p75,p95,p98,p99,p999,mean_rate,m1_rate,m5_rate,m15_rate,rate_unit,duration_unit",
-               "%d,%f,%f,%f,%f,%f,%f,%f,%f,%f,%f,%f,%f,%f,%f,calls/%s,%s",
-               timer.getCount(),
-               convertDuration(snapshot.getMax()),
-               convertDuration(snapshot.getMean()),
-               convertDuration(snapshot.getMin()),
-               convertDuration(snapshot.getStdDev()),
-               convertDuration(snapshot.getMedian()),
-               convertDuration(snapshot.get75thPercentile()),
-               convertDuration(snapshot.get95thPercentile()),
-               convertDuration(snapshot.get98thPercentile()),
-               convertDuration(snapshot.get99thPercentile()),
-               convertDuration(snapshot.get999thPercentile()),
-               convertRate(timer.getMeanRate()),
-               convertRate(timer.getOneMinuteRate()),
-               convertRate(timer.getFiveMinuteRate()),
-               convertRate(timer.getFifteenMinuteRate()),
-               getRateUnit(),
-               getDurationUnit());
+                name,
+                timerHeader,
+                timerFormat,
+                timer.getCount(),
+                convertDuration(snapshot.getMax()),
+                convertDuration(snapshot.getMean()),
+                convertDuration(snapshot.getMin()),
+                convertDuration(snapshot.getStdDev()),
+                convertDuration(snapshot.getMedian()),
+                convertDuration(snapshot.get75thPercentile()),
+                convertDuration(snapshot.get95thPercentile()),
+                convertDuration(snapshot.get98thPercentile()),
+                convertDuration(snapshot.get99thPercentile()),
+                convertDuration(snapshot.get999thPercentile()),
+                convertRate(timer.getMeanRate()),
+                convertRate(timer.getOneMinuteRate()),
+                convertRate(timer.getFiveMinuteRate()),
+                convertRate(timer.getFifteenMinuteRate()),
+                getRateUnit(),
+                getDurationUnit());
     }
 
     private void reportMeter(long timestamp, String name, Meter meter) {
         report(timestamp,
-               name,
-               "count,mean_rate,m1_rate,m5_rate,m15_rate,rate_unit",
-               "%d,%f,%f,%f,%f,events/%s",
-               meter.getCount(),
-               convertRate(meter.getMeanRate()),
-               convertRate(meter.getOneMinuteRate()),
-               convertRate(meter.getFiveMinuteRate()),
-               convertRate(meter.getFifteenMinuteRate()),
-               getRateUnit());
+                name,
+                meterHeader,
+                meterFormat,
+                meter.getCount(),
+                convertRate(meter.getMeanRate()),
+                convertRate(meter.getOneMinuteRate()),
+                convertRate(meter.getFiveMinuteRate()),
+                convertRate(meter.getFifteenMinuteRate()),
+                getRateUnit());
     }
 
     private void reportHistogram(long timestamp, String name, Histogram histogram) {
         final Snapshot snapshot = histogram.getSnapshot();
 
         report(timestamp,
-               name,
-               "count,max,mean,min,stddev,p50,p75,p95,p98,p99,p999",
-               "%d,%d,%f,%d,%f,%f,%f,%f,%f,%f,%f",
-               histogram.getCount(),
-               snapshot.getMax(),
-               snapshot.getMean(),
-               snapshot.getMin(),
-               snapshot.getStdDev(),
-               snapshot.getMedian(),
-               snapshot.get75thPercentile(),
-               snapshot.get95thPercentile(),
-               snapshot.get98thPercentile(),
-               snapshot.get99thPercentile(),
-               snapshot.get999thPercentile());
+                name,
+                histogramHeader,
+                histogramFormat,
+                histogram.getCount(),
+                snapshot.getMax(),
+                snapshot.getMean(),
+                snapshot.getMin(),
+                snapshot.getStdDev(),
+                snapshot.getMedian(),
+                snapshot.get75thPercentile(),
+                snapshot.get95thPercentile(),
+                snapshot.get98thPercentile(),
+                snapshot.get99thPercentile(),
+                snapshot.get999thPercentile());
     }
 
     private void reportCounter(long timestamp, String name, Counter counter) {
         report(timestamp, name, "count", "%d", counter.getCount());
     }
 
-    private void reportGauge(long timestamp, String name, Gauge gauge) {
+    private void reportGauge(long timestamp, String name, Gauge<?> gauge) {
         report(timestamp, name, "value", "%s", gauge.getValue());
     }
 
@@ -285,14 +325,12 @@ public class CsvReporter extends ScheduledReporter {
             final File file = csvFileProvider.getFile(directory, name);
             final boolean fileAlreadyExists = file.exists();
             if (fileAlreadyExists || file.createNewFile()) {
-                final PrintWriter out = new PrintWriter(new OutputStreamWriter(new FileOutputStream(file,true), UTF_8));
-                try {
+                try (PrintWriter out = new PrintWriter(new OutputStreamWriter(
+                        new FileOutputStream(file, true), UTF_8))) {
                     if (!fileAlreadyExists) {
-                        out.println("t," + header);
+                        out.println("t" + separator + header);
                     }
-                    out.printf(locale, String.format(locale, "%d,%s%n", timestamp, line), values);
-                } finally {
-                    out.close();
+                    out.printf(locale, String.format(locale, "%d" + separator + "%s%n", timestamp, line), values);
                 }
             }
         } catch (IOException e) {
diff --git a/metrics-core/src/main/java/com/codahale/metrics/DefaultObjectNameFactory.java b/metrics-core/src/main/java/com/codahale/metrics/DefaultObjectNameFactory.java
deleted file mode 100644
index 17a028c..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/DefaultObjectNameFactory.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.codahale.metrics;
-
-import javax.management.MalformedObjectNameException;
-import javax.management.ObjectName;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class DefaultObjectNameFactory implements ObjectNameFactory {
-
-	private static final Logger LOGGER = LoggerFactory.getLogger(JmxReporter.class);
-
-	@Override
-	public ObjectName createName(String type, String domain, String name) {
-		try {
-			ObjectName objectName = new ObjectName(domain, "name", name);
-			if (objectName.isPattern()) {
-				objectName = new ObjectName(domain, "name", ObjectName.quote(name));
-			}
-			return objectName;
-		} catch (MalformedObjectNameException e) {
-			try {
-				return new ObjectName(domain, "name", ObjectName.quote(name));
-			} catch (MalformedObjectNameException e1) {
-				LOGGER.warn("Unable to register {} {}", type, name, e1);
-				throw new RuntimeException(e1);
-			}
-		}
-	}
-
-}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/DefaultSettableGauge.java b/metrics-core/src/main/java/com/codahale/metrics/DefaultSettableGauge.java
new file mode 100644
index 0000000..d5ef193
--- /dev/null
+++ b/metrics-core/src/main/java/com/codahale/metrics/DefaultSettableGauge.java
@@ -0,0 +1,43 @@
+package com.codahale.metrics;
+
+/**
+ * Similar to {@link Gauge}, but metric value is updated via calling {@link #setValue(T)} instead.
+ */
+public class DefaultSettableGauge<T> implements SettableGauge<T> {
+    private volatile T value;
+
+    /**
+     * Create an instance with no default value.
+     */
+    public DefaultSettableGauge() {
+        this(null);
+    }
+
+    /**
+     * Create an instance with a default value.
+     *
+     * @param defaultValue default value
+     */
+    public DefaultSettableGauge(T defaultValue) {
+        this.value = defaultValue;
+    }
+
+    /**
+     * Set the metric to a new value.
+     */
+    @Override
+    public void setValue(T value) {
+        this.value = value;
+    }
+
+    /**
+     * Returns the current value.
+     *
+     * @return the current value
+     */
+    @Override
+    public T getValue() {
+        return value;
+    }
+
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/EWMA.java b/metrics-core/src/main/java/com/codahale/metrics/EWMA.java
index 7738180..2d2e658 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/EWMA.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/EWMA.java
@@ -1,6 +1,7 @@
 package com.codahale.metrics;
 
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.LongAdder;
 
 import static java.lang.Math.exp;
 
@@ -8,9 +9,9 @@ import static java.lang.Math.exp;
  * An exponentially-weighted moving average.
  *
  * @see <a href="http://www.teamquest.com/pdfs/whitepaper/ldavg1.pdf">UNIX Load Average Part 1: How
- *      It Works</a>
+ * It Works</a>
  * @see <a href="http://www.teamquest.com/pdfs/whitepaper/ldavg2.pdf">UNIX Load Average Part 2: Not
- *      Your Average Average</a>
+ * Your Average Average</a>
  * @see <a href="http://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average">EMA</a>
  */
 public class EWMA {
@@ -26,7 +27,7 @@ public class EWMA {
     private volatile boolean initialized = false;
     private volatile double rate = 0.0;
 
-    private final LongAdderAdapter uncounted = LongAdderProxy.create();
+    private final LongAdder uncounted = new LongAdder();
     private final double alpha, interval;
 
     /**
@@ -87,7 +88,8 @@ public class EWMA {
         final long count = uncounted.sumThenReset();
         final double instantRate = count / interval;
         if (initialized) {
-            rate += (alpha * (instantRate - rate));
+            final double oldRate = this.rate;
+            rate = oldRate + (alpha * (instantRate - oldRate));
         } else {
             rate = instantRate;
             initialized = true;
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ExponentialMovingAverages.java b/metrics-core/src/main/java/com/codahale/metrics/ExponentialMovingAverages.java
new file mode 100644
index 0000000..9879d28
--- /dev/null
+++ b/metrics-core/src/main/java/com/codahale/metrics/ExponentialMovingAverages.java
@@ -0,0 +1,77 @@
+package com.codahale.metrics;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A triple (one, five and fifteen minutes) of exponentially-weighted moving average rates as needed by {@link Meter}.
+ * <p>
+ * The rates have the same exponential decay factor as the fifteen-minute load average in the
+ * {@code top} Unix command.
+ */
+public class ExponentialMovingAverages implements MovingAverages {
+
+    private static final long TICK_INTERVAL = TimeUnit.SECONDS.toNanos(5);
+
+    private final EWMA m1Rate = EWMA.oneMinuteEWMA();
+    private final EWMA m5Rate = EWMA.fiveMinuteEWMA();
+    private final EWMA m15Rate = EWMA.fifteenMinuteEWMA();
+
+    private final AtomicLong lastTick;
+    private final Clock clock;
+
+    /**
+     * Creates a new {@link ExponentialMovingAverages}.
+     */
+    public ExponentialMovingAverages() {
+        this(Clock.defaultClock());
+    }
+
+    /**
+     * Creates a new {@link ExponentialMovingAverages}.
+     */
+    public ExponentialMovingAverages(Clock clock) {
+        this.clock = clock;
+        this.lastTick = new AtomicLong(this.clock.getTick());
+    }
+
+    @Override
+    public void update(long n) {
+        m1Rate.update(n);
+        m5Rate.update(n);
+        m15Rate.update(n);
+    }
+
+    @Override
+    public void tickIfNecessary() {
+        final long oldTick = lastTick.get();
+        final long newTick = clock.getTick();
+        final long age = newTick - oldTick;
+        if (age > TICK_INTERVAL) {
+            final long newIntervalStartTick = newTick - age % TICK_INTERVAL;
+            if (lastTick.compareAndSet(oldTick, newIntervalStartTick)) {
+                final long requiredTicks = age / TICK_INTERVAL;
+                for (long i = 0; i < requiredTicks; i++) {
+                    m1Rate.tick();
+                    m5Rate.tick();
+                    m15Rate.tick();
+                }
+            }
+        }
+    }
+
+    @Override
+    public double getM1Rate() {
+        return m1Rate.getRate(TimeUnit.SECONDS);
+    }
+
+    @Override
+    public double getM5Rate() {
+        return m5Rate.getRate(TimeUnit.SECONDS);
+    }
+
+    @Override
+    public double getM15Rate() {
+        return m15Rate.getRate(TimeUnit.SECONDS);
+    }
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ExponentiallyDecayingReservoir.java b/metrics-core/src/main/java/com/codahale/metrics/ExponentiallyDecayingReservoir.java
index e8ca81e..21cb4c8 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/ExponentiallyDecayingReservoir.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/ExponentiallyDecayingReservoir.java
@@ -2,6 +2,7 @@ package com.codahale.metrics;
 
 import java.util.ArrayList;
 import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
@@ -18,7 +19,7 @@ import com.codahale.metrics.WeightedSnapshot.WeightedSample;
  *
  * @see <a href="http://dimacs.rutgers.edu/~graham/pubs/papers/fwddecay.pdf">
  * Cormode et al. Forward Decay: A Practical Time Decay Model for Streaming Systems. ICDE '09:
- *      Proceedings of the 2009 IEEE International Conference on Data Engineering (2009)</a>
+ * Proceedings of the 2009 IEEE International Conference on Data Engineering (2009)</a>
  */
 public class ExponentiallyDecayingReservoir implements Reservoir {
     private static final int DEFAULT_SIZE = 1028;
@@ -31,7 +32,7 @@ public class ExponentiallyDecayingReservoir implements Reservoir {
     private final int size;
     private final AtomicLong count;
     private volatile long startTime;
-    private final AtomicLong nextScaleTime;
+    private final AtomicLong lastScaleTick;
     private final Clock clock;
 
     /**
@@ -63,14 +64,14 @@ public class ExponentiallyDecayingReservoir implements Reservoir {
      * @param clock the clock used to timestamp samples and track rescaling
      */
     public ExponentiallyDecayingReservoir(int size, double alpha, Clock clock) {
-        this.values = new ConcurrentSkipListMap<Double, WeightedSample>();
+        this.values = new ConcurrentSkipListMap<>();
         this.lock = new ReentrantReadWriteLock();
         this.alpha = alpha;
         this.size = size;
         this.clock = clock;
         this.count = new AtomicLong(0);
         this.startTime = currentTimeInSeconds();
-        this.nextScaleTime = new AtomicLong(clock.getTick() + RESCALE_THRESHOLD);
+        this.lastScaleTick = new AtomicLong(clock.getTick());
     }
 
     @Override
@@ -95,10 +96,10 @@ public class ExponentiallyDecayingReservoir implements Reservoir {
         try {
             final double itemWeight = weight(timestamp - startTime);
             final WeightedSample sample = new WeightedSample(value, itemWeight);
-            final double priority = itemWeight / ThreadLocalRandomProxy.current().nextDouble();
-            
+            final double priority = itemWeight / ThreadLocalRandom.current().nextDouble();
+
             final long newCount = count.incrementAndGet();
-            if (newCount <= size) {
+            if (newCount <= size || values.isEmpty()) {
                 values.put(priority, sample);
             } else {
                 Double first = values.firstKey();
@@ -116,9 +117,9 @@ public class ExponentiallyDecayingReservoir implements Reservoir {
 
     private void rescaleIfNeeded() {
         final long now = clock.getTick();
-        final long next = nextScaleTime.get();
-        if (now >= next) {
-            rescale(now, next);
+        final long lastScaleTickSnapshot = lastScaleTick.get();
+        if (now - lastScaleTickSnapshot >= RESCALE_THRESHOLD) {
+            rescale(now, lastScaleTickSnapshot);
         }
     }
 
@@ -159,17 +160,17 @@ public class ExponentiallyDecayingReservoir implements Reservoir {
      * landmark Lβ€² (and then use this new Lβ€² at query time). This can be done with
      * a linear pass over whatever data structure is being used."
      */
-    private void rescale(long now, long next) {
+    private void rescale(long now, long lastTick) {
         lockForRescale();
         try {
-            if (nextScaleTime.compareAndSet(next, now + RESCALE_THRESHOLD)) {
+            if (lastScaleTick.compareAndSet(lastTick, now)) {
                 final long oldStartTime = startTime;
                 this.startTime = currentTimeInSeconds();
                 final double scalingFactor = exp(-alpha * (startTime - oldStartTime));
                 if (Double.compare(scalingFactor, 0) == 0) {
                     values.clear();
                 } else {
-                    final ArrayList<Double> keys = new ArrayList<Double>(values.keySet());
+                    final ArrayList<Double> keys = new ArrayList<>(values.keySet());
                     for (Double key : keys) {
                         final WeightedSample sample = values.remove(key);
                         final WeightedSample newSample = new WeightedSample(sample.value, sample.weight * scalingFactor);
diff --git a/metrics-core/src/main/java/com/codahale/metrics/FixedNameCsvFileProvider.java b/metrics-core/src/main/java/com/codahale/metrics/FixedNameCsvFileProvider.java
index eaa8538..db91c17 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/FixedNameCsvFileProvider.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/FixedNameCsvFileProvider.java
@@ -16,7 +16,6 @@ public class FixedNameCsvFileProvider implements CsvFileProvider {
     protected String sanitize(String metricName) {
         //Forward slash character is definitely illegal in both Windows and Linux
         //https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
-        final String sanitizedName = metricName.replaceFirst("^/","").replaceAll("/",".");
-        return sanitizedName;
+        return metricName.replaceFirst("^/", "").replaceAll("/", ".");
     }
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Gauge.java b/metrics-core/src/main/java/com/codahale/metrics/Gauge.java
index 09a64e9..eade65b 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Gauge.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Gauge.java
@@ -15,6 +15,7 @@ package com.codahale.metrics;
  *
  * @param <T> the type of the metric's value
  */
+@FunctionalInterface
 public interface Gauge<T> extends Metric {
     /**
      * Returns the metric's current value.
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Histogram.java b/metrics-core/src/main/java/com/codahale/metrics/Histogram.java
index 48f877b..e499a07 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Histogram.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Histogram.java
@@ -1,14 +1,16 @@
 package com.codahale.metrics;
 
+import java.util.concurrent.atomic.LongAdder;
+
 /**
  * A metric which calculates the distribution of a value.
  *
  * @see <a href="http://www.johndcook.com/standard_deviation.html">Accurately computing running
- *      variance</a>
+ * variance</a>
  */
 public class Histogram implements Metric, Sampling, Counting {
     private final Reservoir reservoir;
-    private final LongAdderAdapter count;
+    private final LongAdder count;
 
     /**
      * Creates a new {@link Histogram} with the given reservoir.
@@ -17,7 +19,7 @@ public class Histogram implements Metric, Sampling, Counting {
      */
     public Histogram(Reservoir reservoir) {
         this.reservoir = reservoir;
-        this.count = LongAdderProxy.create();
+        this.count = new LongAdder();
     }
 
     /**
diff --git a/metrics-core/src/main/java/com/codahale/metrics/InstrumentedExecutorService.java b/metrics-core/src/main/java/com/codahale/metrics/InstrumentedExecutorService.java
index 1aa1d01..9603515 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/InstrumentedExecutorService.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/InstrumentedExecutorService.java
@@ -3,28 +3,32 @@ package com.codahale.metrics;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ForkJoinPool;
 import java.util.concurrent.Future;
-import java.util.concurrent.Callable;
+import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * An {@link ExecutorService} that monitors the number of tasks submitted, running,
  * completed and also keeps a {@link Timer} for the task duration.
- * <p/>
+ * <p>
  * It will register the metrics using the given (or auto-generated) name as classifier, e.g:
  * "your-executor-service.submitted", "your-executor-service.running", etc.
  */
 public class InstrumentedExecutorService implements ExecutorService {
-    private static final AtomicLong nameCounter = new AtomicLong();
+    private static final AtomicLong NAME_COUNTER = new AtomicLong();
 
     private final ExecutorService delegate;
     private final Meter submitted;
     private final Counter running;
     private final Meter completed;
+    private final Timer idle;
     private final Timer duration;
 
     /**
@@ -34,7 +38,7 @@ public class InstrumentedExecutorService implements ExecutorService {
      * @param registry {@link MetricRegistry} that will contain the metrics.
      */
     public InstrumentedExecutorService(ExecutorService delegate, MetricRegistry registry) {
-        this(delegate, registry, "instrumented-delegate-" + nameCounter.incrementAndGet());
+        this(delegate, registry, "instrumented-delegate-" + NAME_COUNTER.incrementAndGet());
     }
 
     /**
@@ -49,7 +53,37 @@ public class InstrumentedExecutorService implements ExecutorService {
         this.submitted = registry.meter(MetricRegistry.name(name, "submitted"));
         this.running = registry.counter(MetricRegistry.name(name, "running"));
         this.completed = registry.meter(MetricRegistry.name(name, "completed"));
+        this.idle = registry.timer(MetricRegistry.name(name, "idle"));
         this.duration = registry.timer(MetricRegistry.name(name, "duration"));
+
+        if (delegate instanceof ThreadPoolExecutor) {
+            ThreadPoolExecutor executor = (ThreadPoolExecutor) delegate;
+            registry.registerGauge(MetricRegistry.name(name, "pool.size"),
+                    executor::getPoolSize);
+            registry.registerGauge(MetricRegistry.name(name, "pool.core"),
+                    executor::getCorePoolSize);
+            registry.registerGauge(MetricRegistry.name(name, "pool.max"),
+                    executor::getMaximumPoolSize);
+            final BlockingQueue<Runnable> queue = executor.getQueue();
+            registry.registerGauge(MetricRegistry.name(name, "tasks.active"),
+                    executor::getActiveCount);
+            registry.registerGauge(MetricRegistry.name(name, "tasks.completed"),
+                    executor::getCompletedTaskCount);
+            registry.registerGauge(MetricRegistry.name(name, "tasks.queued"),
+                    queue::size);
+            registry.registerGauge(MetricRegistry.name(name, "tasks.capacity"),
+                    queue::remainingCapacity);
+        } else if (delegate instanceof ForkJoinPool) {
+            ForkJoinPool forkJoinPool = (ForkJoinPool) delegate;
+            registry.registerGauge(MetricRegistry.name(name, "tasks.stolen"),
+                    forkJoinPool::getStealCount);
+            registry.registerGauge(MetricRegistry.name(name, "tasks.queued"),
+                    forkJoinPool::getQueuedTaskCount);
+            registry.registerGauge(MetricRegistry.name(name, "threads.active"),
+                    forkJoinPool::getActiveThreadCount);
+            registry.registerGauge(MetricRegistry.name(name, "threads.running"),
+                    forkJoinPool::getRunningThreadCount);
+        }
     }
 
     /**
@@ -85,7 +119,7 @@ public class InstrumentedExecutorService implements ExecutorService {
     @Override
     public <T> Future<T> submit(Callable<T> task) {
         submitted.mark();
-        return delegate.submit(new InstrumentedCallable<T>(task));
+        return delegate.submit(new InstrumentedCallable<>(task));
     }
 
     /**
@@ -129,9 +163,9 @@ public class InstrumentedExecutorService implements ExecutorService {
     }
 
     private <T> Collection<? extends Callable<T>> instrument(Collection<? extends Callable<T>> tasks) {
-        final List<InstrumentedCallable<T>> instrumented = new ArrayList<InstrumentedCallable<T>>(tasks.size());
+        final List<InstrumentedCallable<T>> instrumented = new ArrayList<>(tasks.size());
         for (Callable<T> task : tasks) {
-            instrumented.add(new InstrumentedCallable<T>(task));
+            instrumented.add(new InstrumentedCallable<>(task));
         }
         return instrumented;
     }
@@ -163,19 +197,20 @@ public class InstrumentedExecutorService implements ExecutorService {
 
     private class InstrumentedRunnable implements Runnable {
         private final Runnable task;
+        private final Timer.Context idleContext;
 
         InstrumentedRunnable(Runnable task) {
             this.task = task;
+            this.idleContext = idle.time();
         }
 
         @Override
         public void run() {
+            idleContext.stop();
             running.inc();
-            final Timer.Context context = duration.time();
-            try {
+            try (Timer.Context durationContext = duration.time()) {
                 task.run();
             } finally {
-                context.stop();
                 running.dec();
                 completed.mark();
             }
@@ -184,19 +219,20 @@ public class InstrumentedExecutorService implements ExecutorService {
 
     private class InstrumentedCallable<T> implements Callable<T> {
         private final Callable<T> callable;
+        private final Timer.Context idleContext;
 
         InstrumentedCallable(Callable<T> callable) {
             this.callable = callable;
+            this.idleContext = idle.time();
         }
 
         @Override
         public T call() throws Exception {
+            idleContext.stop();
             running.inc();
-            final Timer.Context context = duration.time();
-            try {
+            try (Timer.Context context = duration.time()) {
                 return callable.call();
             } finally {
-                context.stop();
                 running.dec();
                 completed.mark();
             }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/InstrumentedScheduledExecutorService.java b/metrics-core/src/main/java/com/codahale/metrics/InstrumentedScheduledExecutorService.java
index b6a67d3..2491571 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/InstrumentedScheduledExecutorService.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/InstrumentedScheduledExecutorService.java
@@ -3,18 +3,24 @@ package com.codahale.metrics;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import java.util.concurrent.*;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * An {@link ScheduledExecutorService} that monitors the number of tasks submitted, running,
  * completed and also keeps a {@link Timer} for the task duration.
- * <p/>
+ * <p>
  * It will register the metrics using the given (or auto-generated) name as classifier, e.g:
  * "your-executor-service.submitted", "your-executor-service.running", etc.
  */
 public class InstrumentedScheduledExecutorService implements ScheduledExecutorService {
-    private static final AtomicLong nameCounter = new AtomicLong();
+    private static final AtomicLong NAME_COUNTER = new AtomicLong();
 
     private final ScheduledExecutorService delegate;
 
@@ -35,7 +41,7 @@ public class InstrumentedScheduledExecutorService implements ScheduledExecutorSe
      * @param registry {@link MetricRegistry} that will contain the metrics.
      */
     public InstrumentedScheduledExecutorService(ScheduledExecutorService delegate, MetricRegistry registry) {
-        this(delegate, registry, "instrumented-scheduled-executor-service-" + nameCounter.incrementAndGet());
+        this(delegate, registry, "instrumented-scheduled-executor-service-" + NAME_COUNTER.incrementAndGet());
     }
 
     /**
@@ -75,7 +81,7 @@ public class InstrumentedScheduledExecutorService implements ScheduledExecutorSe
     @Override
     public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
         scheduledOnce.mark();
-        return delegate.schedule(new InstrumentedCallable<V>(callable), delay, unit);
+        return delegate.schedule(new InstrumentedCallable<>(callable), delay, unit);
     }
 
     /**
@@ -142,7 +148,7 @@ public class InstrumentedScheduledExecutorService implements ScheduledExecutorSe
     @Override
     public <T> Future<T> submit(Callable<T> task) {
         submitted.mark();
-        return delegate.submit(new InstrumentedCallable<T>(task));
+        return delegate.submit(new InstrumentedCallable<>(task));
     }
 
     /**
@@ -204,9 +210,9 @@ public class InstrumentedScheduledExecutorService implements ScheduledExecutorSe
     }
 
     private <T> Collection<? extends Callable<T>> instrument(Collection<? extends Callable<T>> tasks) {
-        final List<InstrumentedCallable<T>> instrumented = new ArrayList<InstrumentedCallable<T>>(tasks.size());
+        final List<InstrumentedCallable<T>> instrumented = new ArrayList<>(tasks.size());
         for (Callable<T> task : tasks) {
-            instrumented.add(new InstrumentedCallable(task));
+            instrumented.add(new InstrumentedCallable<>(task));
         }
         return instrumented;
     }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/InstrumentedThreadFactory.java b/metrics-core/src/main/java/com/codahale/metrics/InstrumentedThreadFactory.java
index 2f29fed..b64c05f 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/InstrumentedThreadFactory.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/InstrumentedThreadFactory.java
@@ -5,12 +5,12 @@ import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * A {@link ThreadFactory} that monitors the number of threads created, running and terminated.
- * <p/>
+ * <p>
  * It will register the metrics using the given (or auto-generated) name as classifier, e.g:
  * "your-thread-delegate.created", "your-thread-delegate.running", etc.
  */
 public class InstrumentedThreadFactory implements ThreadFactory {
-    private static final AtomicLong nameCounter = new AtomicLong();
+    private static final AtomicLong NAME_COUNTER = new AtomicLong();
 
     private final ThreadFactory delegate;
     private final Meter created;
@@ -24,7 +24,7 @@ public class InstrumentedThreadFactory implements ThreadFactory {
      * @param registry {@link MetricRegistry} that will contain the metrics.
      */
     public InstrumentedThreadFactory(ThreadFactory delegate, MetricRegistry registry) {
-        this(delegate, registry, "instrumented-thread-delegate-" + nameCounter.incrementAndGet());
+        this(delegate, registry, "instrumented-thread-delegate-" + NAME_COUNTER.incrementAndGet());
     }
 
     /**
diff --git a/metrics-core/src/main/java/com/codahale/metrics/JvmAttributeGaugeSet.java b/metrics-core/src/main/java/com/codahale/metrics/JvmAttributeGaugeSet.java
deleted file mode 100644
index 77502c6..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/JvmAttributeGaugeSet.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.codahale.metrics;
-
-import java.lang.management.ManagementFactory;
-import java.lang.management.RuntimeMXBean;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-
-/**
- * A set of gauges for the JVM name, vendor, and uptime.
- */
-public class JvmAttributeGaugeSet implements MetricSet {
-    private final RuntimeMXBean runtime;
-
-    /**
-     * Creates a new set of gauges.
-     */
-    public JvmAttributeGaugeSet() {
-        this(ManagementFactory.getRuntimeMXBean());
-    }
-
-    /**
-     * Creates a new set of gauges with the given {@link RuntimeMXBean}.
-     * @param runtime JVM management interface with access to system properties
-     */
-    public JvmAttributeGaugeSet(RuntimeMXBean runtime) {
-        this.runtime = runtime;
-    }
-
-    @Override
-    public Map<String, Metric> getMetrics() {
-        final Map<String, Metric> gauges = new HashMap<String, Metric>();
-
-        gauges.put("name", new Gauge<String>() {
-            @Override
-            public String getValue() {
-                return runtime.getName();
-            }
-        });
-
-        gauges.put("vendor", new Gauge<String>() {
-            @Override
-            public String getValue() {
-                return String.format(Locale.US,
-                                     "%s %s %s (%s)",
-                                     runtime.getVmVendor(),
-                                     runtime.getVmName(),
-                                     runtime.getVmVersion(),
-                                     runtime.getSpecVersion());
-            }
-        });
-
-        gauges.put("uptime", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return runtime.getUptime();
-            }
-        });
-
-        return Collections.unmodifiableMap(gauges);
-    }
-}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/LockFreeExponentiallyDecayingReservoir.java b/metrics-core/src/main/java/com/codahale/metrics/LockFreeExponentiallyDecayingReservoir.java
new file mode 100644
index 0000000..cf258f0
--- /dev/null
+++ b/metrics-core/src/main/java/com/codahale/metrics/LockFreeExponentiallyDecayingReservoir.java
@@ -0,0 +1,270 @@
+package com.codahale.metrics;
+
+import com.codahale.metrics.WeightedSnapshot.WeightedSample;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
+import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
+import java.util.function.BiConsumer;
+
+/**
+ * A lock-free exponentially-decaying random reservoir of {@code long}s. Uses Cormode et al's
+ * forward-decaying priority reservoir sampling method to produce a statistically representative
+ * sampling reservoir, exponentially biased towards newer entries.
+ *
+ * @see <a href="http://dimacs.rutgers.edu/~graham/pubs/papers/fwddecay.pdf">
+ * Cormode et al. Forward Decay: A Practical Time Decay Model for Streaming Systems. ICDE '09:
+ * Proceedings of the 2009 IEEE International Conference on Data Engineering (2009)</a>
+ *
+ * {@link LockFreeExponentiallyDecayingReservoir} is based closely on the {@link ExponentiallyDecayingReservoir},
+ * however it provides looser guarantees while completely avoiding locks.
+ *
+ * Looser guarantees:
+ * <ul>
+ *     <li> Updates which occur concurrently with rescaling may be discarded if the orphaned state node is updated after
+ *     rescale has replaced it. This condition has a greater probability as the rescale interval is reduced due to the
+ *     increased frequency of rescaling. {@link #rescaleThresholdNanos} values below 30 seconds are not recommended.
+ *     <li> Given a small rescale threshold, updates may attempt to rescale into a new bucket, but lose the CAS race
+ *     and update into a newer bucket than expected. In these cases the measurement weight is reduced accordingly.
+ *     <li>In the worst case, all concurrent threads updating the reservoir may attempt to rescale rather than
+ *     a single thread holding an exclusive write lock. It's expected that the configuration is set such that
+ *     rescaling is substantially less common than updating at peak load. Even so, when size is reasonably small
+ *     it can be more efficient to rescale than to park and context switch.
+ * </ul>
+ *
+ * @author <a href="mailto:ckozak@ckozak.net">Carter Kozak</a>
+ */
+public final class LockFreeExponentiallyDecayingReservoir implements Reservoir {
+
+    private static final double SECONDS_PER_NANO = .000_000_001D;
+    private static final AtomicReferenceFieldUpdater<LockFreeExponentiallyDecayingReservoir, State> stateUpdater =
+            AtomicReferenceFieldUpdater.newUpdater(LockFreeExponentiallyDecayingReservoir.class, State.class, "state");
+
+    private final int size;
+    private final long rescaleThresholdNanos;
+    private final Clock clock;
+
+    private volatile State state;
+
+    private static final class State {
+
+        private static final AtomicIntegerFieldUpdater<State> countUpdater =
+                AtomicIntegerFieldUpdater.newUpdater(State.class, "count");
+
+        private final double alphaNanos;
+        private final int size;
+        private final long startTick;
+        // Count is updated after samples are successfully added to the map.
+        private final ConcurrentSkipListMap<Double, WeightedSample> values;
+
+        private volatile int count;
+
+        State(
+                double alphaNanos,
+                int size,
+                long startTick,
+                int count,
+                ConcurrentSkipListMap<Double, WeightedSample> values) {
+            this.alphaNanos = alphaNanos;
+            this.size = size;
+            this.startTick = startTick;
+            this.values = values;
+            this.count = count;
+        }
+
+        private void update(long value, long timestampNanos) {
+            double itemWeight = weight(timestampNanos - startTick);
+            double priority = itemWeight / ThreadLocalRandom.current().nextDouble();
+            boolean mapIsFull = count >= size;
+            if (!mapIsFull || values.firstKey() < priority) {
+                addSample(priority, value, itemWeight, mapIsFull);
+            }
+        }
+
+        private void addSample(double priority, long value, double itemWeight, boolean bypassIncrement) {
+            if (values.putIfAbsent(priority, new WeightedSample(value, itemWeight)) == null
+                    && (bypassIncrement || countUpdater.incrementAndGet(this) > size)) {
+                values.pollFirstEntry();
+            }
+        }
+
+        /* "A common feature of the above techniquesβ€”indeed, the key technique that
+         * allows us to track the decayed weights efficientlyβ€”is that they maintain
+         * counts and other quantities based on g(ti βˆ’ L), and only scale by g(t βˆ’ L)
+         * at query time. But while g(ti βˆ’L)/g(tβˆ’L) is guaranteed to lie between zero
+         * and one, the intermediate values of g(ti βˆ’ L) could become very large. For
+         * polynomial functions, these values should not grow too large, and should be
+         * effectively represented in practice by floating point values without loss of
+         * precision. For exponential functions, these values could grow quite large as
+         * new values of (ti βˆ’ L) become large, and potentially exceed the capacity of
+         * common floating point types. However, since the values stored by the
+         * algorithms are linear combinations of g values (scaled sums), they can be
+         * rescaled relative to a new landmark. That is, by the analysis of exponential
+         * decay in Section III-A, the choice of L does not affect the final result. We
+         * can therefore multiply each value based on L by a factor of exp(βˆ’Ξ±(Lβ€² βˆ’ L)),
+         * and obtain the correct value as if we had instead computed relative to a new
+         * landmark Lβ€² (and then use this new Lβ€² at query time). This can be done with
+         * a linear pass over whatever data structure is being used."
+         */
+        State rescale(long newTick) {
+            long durationNanos = newTick - startTick;
+            double scalingFactor = Math.exp(-alphaNanos * durationNanos);
+            int newCount = 0;
+            ConcurrentSkipListMap<Double, WeightedSample> newValues = new ConcurrentSkipListMap<>();
+            if (Double.compare(scalingFactor, 0) != 0) {
+                RescalingConsumer consumer = new RescalingConsumer(scalingFactor, newValues);
+                values.forEach(consumer);
+                // make sure the counter is in sync with the number of stored samples.
+                newCount = consumer.count;
+            }
+            // It's possible that more values were added while the map was scanned, those with the
+            // minimum priorities are removed.
+            while (newCount > size) {
+                Objects.requireNonNull(newValues.pollFirstEntry(), "Expected an entry");
+                newCount--;
+            }
+            return new State(alphaNanos, size, newTick, newCount, newValues);
+        }
+
+        private double weight(long durationNanos) {
+            return Math.exp(alphaNanos * durationNanos);
+        }
+    }
+
+    private static final class RescalingConsumer implements BiConsumer<Double, WeightedSample> {
+        private final double scalingFactor;
+        private final ConcurrentSkipListMap<Double, WeightedSample> values;
+        private int count;
+
+        RescalingConsumer(double scalingFactor, ConcurrentSkipListMap<Double, WeightedSample> values) {
+            this.scalingFactor = scalingFactor;
+            this.values = values;
+        }
+
+        @Override
+        public void accept(Double priority, WeightedSample sample) {
+            double newWeight = sample.weight * scalingFactor;
+            if (Double.compare(newWeight, 0) == 0) {
+                return;
+            }
+            WeightedSample newSample = new WeightedSample(sample.value, newWeight);
+            if (values.put(priority * scalingFactor, newSample) == null) {
+                count++;
+            }
+        }
+    }
+
+    private LockFreeExponentiallyDecayingReservoir(int size, double alpha, Duration rescaleThreshold, Clock clock) {
+        // Scale alpha to nanoseconds
+        double alphaNanos = alpha * SECONDS_PER_NANO;
+        this.size = size;
+        this.clock = clock;
+        this.rescaleThresholdNanos = rescaleThreshold.toNanos();
+        this.state = new State(alphaNanos, size, clock.getTick(), 0, new ConcurrentSkipListMap<>());
+    }
+
+    @Override
+    public int size() {
+        return Math.min(size, state.count);
+    }
+
+    @Override
+    public void update(long value) {
+        long now = clock.getTick();
+        rescaleIfNeeded(now).update(value, now);
+    }
+
+    private State rescaleIfNeeded(long currentTick) {
+        // This method is optimized for size so the check may be quickly inlined.
+        // Rescaling occurs substantially less frequently than the check itself.
+        State stateSnapshot = this.state;
+        if (currentTick - stateSnapshot.startTick >= rescaleThresholdNanos) {
+            return doRescale(currentTick, stateSnapshot);
+        }
+        return stateSnapshot;
+    }
+
+    private State doRescale(long currentTick, State stateSnapshot) {
+        State newState = stateSnapshot.rescale(currentTick);
+        if (stateUpdater.compareAndSet(this, stateSnapshot, newState)) {
+            // newState successfully installed
+            return newState;
+        }
+        // Otherwise another thread has won the race and we can return the result of a volatile read.
+        // It's possible this has taken so long that another update is required, however that's unlikely
+        // and no worse than the standard race between a rescale and update.
+        return this.state;
+    }
+
+    @Override
+    public Snapshot getSnapshot() {
+        State stateSnapshot = rescaleIfNeeded(clock.getTick());
+        return new WeightedSnapshot(stateSnapshot.values.values());
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /**
+     * By default this uses a size of 1028 elements, which offers a 99.9%
+     * confidence level with a 5% margin of error assuming a normal distribution, and an alpha
+     * factor of 0.015, which heavily biases the reservoir to the past 5 minutes of measurements.
+     */
+    public static final class Builder {
+        private static final int DEFAULT_SIZE = 1028;
+        private static final double DEFAULT_ALPHA = 0.015D;
+        private static final Duration DEFAULT_RESCALE_THRESHOLD = Duration.ofHours(1);
+
+        private int size = DEFAULT_SIZE;
+        private double alpha = DEFAULT_ALPHA;
+        private Duration rescaleThreshold = DEFAULT_RESCALE_THRESHOLD;
+        private Clock clock = Clock.defaultClock();
+
+        private Builder() {}
+
+        /**
+         * Maximum number of samples to keep in the reservoir. Once this number is reached older samples are
+         * replaced (based on weight, with some amount of random jitter).
+         */
+        public Builder size(int value) {
+            if (value <= 0) {
+                throw new IllegalArgumentException(
+                        "LockFreeExponentiallyDecayingReservoir size must be positive: " + value);
+            }
+            this.size = value;
+            return this;
+        }
+
+        /**
+         * Alpha is the exponential decay factor. Higher values bias results more heavily toward newer values.
+         */
+        public Builder alpha(double value) {
+            this.alpha = value;
+            return this;
+        }
+
+        /**
+         * Interval at which this reservoir is rescaled.
+         */
+        public Builder rescaleThreshold(Duration value) {
+            this.rescaleThreshold = Objects.requireNonNull(value, "rescaleThreshold is required");
+            return this;
+        }
+
+        /**
+         * Clock instance used for decay.
+         */
+        public Builder clock(Clock value) {
+            this.clock = Objects.requireNonNull(value, "clock is required");
+            return this;
+        }
+
+        public Reservoir build() {
+            return new LockFreeExponentiallyDecayingReservoir(size, alpha, rescaleThreshold, clock);
+        }
+    }
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/LongAdder.java b/metrics-core/src/main/java/com/codahale/metrics/LongAdder.java
deleted file mode 100644
index 5299ee9..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/LongAdder.java
+++ /dev/null
@@ -1,197 +0,0 @@
-/*
- * Written by Doug Lea with assistance from members of JCP JSR-166
- * Expert Group and released to the public domain, as explained at
- * http://creativecommons.org/publicdomain/zero/1.0/
- *
- * http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/src/jsr166e/LongAdder.java?revision=1.14&view=markup
- */
-
-package com.codahale.metrics;
-
-import java.io.Serializable;
-import java.util.concurrent.atomic.AtomicLong;
-
-// CHECKSTYLE:OFF
-/**
- * One or more variables that together maintain an initially zero {@code long} sum.  When updates
- * (method {@link #add}) are contended across threads, the set of variables may grow dynamically to
- * reduce contention. Method {@link #sum} (or, equivalently, {@link #longValue}) returns the current
- * total combined across the variables maintaining the sum.
- * <p/>
- * <p>This class is usually preferable to {@link AtomicLong} when multiple threads update a common
- * sum that is used for purposes such as collecting statistics, not for fine-grained synchronization
- * control.  Under low update contention, the two classes have similar characteristics. But under
- * high contention, expected throughput of this class is significantly higher, at the expense of
- * higher space consumption.
- * <p/>
- * <p>This class extends {@link Number}, but does <em>not</em> define methods such as {@code
- * equals}, {@code hashCode} and {@code compareTo} because instances are expected to be mutated, and
- * so are not useful as collection keys.
- * <p/>
- * <p><em>jsr166e note: This class is targeted to be placed in java.util.concurrent.atomic.</em>
- *
- * @author Doug Lea
- * @since 1.8
- */
-@SuppressWarnings("all")
-class LongAdder extends Striped64 implements Serializable {
-    private static final long serialVersionUID = 7249069246863182397L;
-
-    /**
-     * Version of plus for use in retryUpdate
-     */
-    final long fn(long v, long x) {
-        return v + x;
-    }
-
-    /**
-     * Creates a new adder with initial sum of zero.
-     */
-    LongAdder() {
-    }
-
-    /**
-     * Adds the given value.
-     *
-     * @param x the value to add
-     */
-    public void add(long x) {
-        Cell[] as;
-        long b, v;
-        HashCode hc;
-        Cell a;
-        int n;
-        if ((as = cells) != null || !casBase(b = base, b + x)) {
-            boolean uncontended = true;
-            int h = (hc = threadHashCode.get()).code;
-            if (as == null || (n = as.length) < 1 ||
-                    (a = as[(n - 1) & h]) == null ||
-                    !(uncontended = a.cas(v = a.value, v + x)))
-                retryUpdate(x, hc, uncontended);
-        }
-    }
-
-    /**
-     * Equivalent to {@code add(1)}.
-     */
-    public void increment() {
-        add(1L);
-    }
-
-    /**
-     * Equivalent to {@code add(-1)}.
-     */
-    public void decrement() {
-        add(-1L);
-    }
-
-    /**
-     * Returns the current sum.  The returned value is <em>NOT</em> an atomic snapshot; invocation
-     * in the absence of concurrent updates returns an accurate result, but concurrent updates that
-     * occur while the sum is being calculated might not be incorporated.
-     *
-     * @return the sum
-     */
-    public long sum() {
-        long sum = base;
-        Cell[] as = cells;
-        if (as != null) {
-            int n = as.length;
-            for (int i = 0; i < n; ++i) {
-                Cell a = as[i];
-                if (a != null)
-                    sum += a.value;
-            }
-        }
-        return sum;
-    }
-
-    /**
-     * Resets variables maintaining the sum to zero.  This method may be a useful alternative to
-     * creating a new adder, but is only effective if there are no concurrent updates.  Because this
-     * method is intrinsically racy, it should only be used when it is known that no threads are
-     * concurrently updating.
-     */
-    public void reset() {
-        internalReset(0L);
-    }
-
-    /**
-     * Equivalent in effect to {@link #sum} followed by {@link #reset}. This method may apply for
-     * example during quiescent points between multithreaded computations.  If there are updates
-     * concurrent with this method, the returned value is <em>not</em> guaranteed to be the final
-     * value occurring before the reset.
-     *
-     * @return the sum
-     */
-    public long sumThenReset() {
-        long sum = base;
-        Cell[] as = cells;
-        base = 0L;
-        if (as != null) {
-            int n = as.length;
-            for (int i = 0; i < n; ++i) {
-                Cell a = as[i];
-                if (a != null) {
-                    sum += a.value;
-                    a.value = 0L;
-                }
-            }
-        }
-        return sum;
-    }
-
-    /**
-     * Returns the String representation of the {@link #sum}.
-     *
-     * @return the String representation of the {@link #sum}
-     */
-    public String toString() {
-        return Long.toString(sum());
-    }
-
-    /**
-     * Equivalent to {@link #sum}.
-     *
-     * @return the sum
-     */
-    public long longValue() {
-        return sum();
-    }
-
-    /**
-     * Returns the {@link #sum} as an {@code int} after a narrowing primitive conversion.
-     */
-    public int intValue() {
-        return (int) sum();
-    }
-
-    /**
-     * Returns the {@link #sum} as a {@code float} after a widening primitive conversion.
-     */
-    public float floatValue() {
-        return (float) sum();
-    }
-
-    /**
-     * Returns the {@link #sum} as a {@code double} after a widening primitive conversion.
-     */
-    public double doubleValue() {
-        return (double) sum();
-    }
-
-    private void writeObject(java.io.ObjectOutputStream s)
-            throws java.io.IOException {
-        s.defaultWriteObject();
-        s.writeLong(sum());
-    }
-
-    private void readObject(java.io.ObjectInputStream s)
-            throws java.io.IOException, ClassNotFoundException {
-        s.defaultReadObject();
-        busy = 0;
-        cells = null;
-        base = s.readLong();
-    }
-}
-// CHECKSTYLE:ON
diff --git a/metrics-core/src/main/java/com/codahale/metrics/LongAdderAdapter.java b/metrics-core/src/main/java/com/codahale/metrics/LongAdderAdapter.java
deleted file mode 100644
index d38febd..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/LongAdderAdapter.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.codahale.metrics;
-
-/**
- * Interface which exposes the LongAdder functionality. Allows different
- * LongAdder implementations to coexist together.
- */
-interface LongAdderAdapter {
-
-    void add(long x);
-
-    long sum();
-
-    void increment();
-
-    void decrement();
-
-    long sumThenReset();
-}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/LongAdderProxy.java b/metrics-core/src/main/java/com/codahale/metrics/LongAdderProxy.java
deleted file mode 100644
index bec8ab9..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/LongAdderProxy.java
+++ /dev/null
@@ -1,108 +0,0 @@
-package com.codahale.metrics;
-
-/**
- * Proxy for creating long adders depending on the runtime. By default it tries to
- * the JDK's implementation and fallbacks to the internal one if the JDK doesn't provide
- * any. The JDK's LongAdder and the internal one don't have a common interface, therefore
- * we adapten them to {@link InternalLongAdderProvider}, which serves as a common interface for
- * long adders.
- */
-class LongAdderProxy {
-
-    private interface Provider {
-        LongAdderAdapter get();
-    }
-
-    /**
-     * To avoid NoClassDefFoundError during loading {@link LongAdderProxy}
-     */
-    private static class JdkProvider implements Provider {
-
-        @Override
-        public LongAdderAdapter get() {
-            return new LongAdderAdapter() {
-                private final java.util.concurrent.atomic.LongAdder longAdder =
-                        new java.util.concurrent.atomic.LongAdder();
-
-                @Override
-                public void add(long x) {
-                    longAdder.add(x);
-                }
-
-                @Override
-                public long sum() {
-                    return longAdder.sum();
-                }
-
-                @Override
-                public void increment() {
-                    longAdder.increment();
-                }
-
-                @Override
-                public void decrement() {
-                    longAdder.decrement();
-                }
-
-                @Override
-                public long sumThenReset() {
-                    return longAdder.sumThenReset();
-                }
-            };
-        }
-    }
-
-    /**
-     * Backed by the internal LongAdder
-     */
-    private static class InternalLongAdderProvider implements Provider {
-
-        @Override
-        public LongAdderAdapter get() {
-            return new LongAdderAdapter() {
-                private final LongAdder longAdder = new LongAdder();
-
-                @Override
-                public void add(long x) {
-                    longAdder.add(x);
-                }
-
-                @Override
-                public long sum() {
-                    return longAdder.sum();
-                }
-
-                @Override
-                public void increment() {
-                    longAdder.increment();
-                }
-
-                @Override
-                public void decrement() {
-                    longAdder.decrement();
-                }
-
-                @Override
-                public long sumThenReset() {
-                    return longAdder.sumThenReset();
-                }
-            };
-        }
-
-    }
-
-    private static final Provider INSTANCE = getLongAdderProvider();
-    private static Provider getLongAdderProvider() {
-        try {
-            final JdkProvider jdkProvider = new JdkProvider();
-            jdkProvider.get(); // To trigger a possible `NoClassDefFoundError` exception
-            return jdkProvider;
-        } catch (Throwable e) {
-            return new InternalLongAdderProvider();
-        }
-    }
-
-    public static LongAdderAdapter create() {
-        return INSTANCE.get();
-    }
-}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Meter.java b/metrics-core/src/main/java/com/codahale/metrics/Meter.java
index 1f8b80b..c153bfa 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Meter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Meter.java
@@ -1,26 +1,30 @@
 package com.codahale.metrics;
 
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.LongAdder;
 
 /**
  * A meter metric which measures mean throughput and one-, five-, and fifteen-minute
- * exponentially-weighted moving average throughputs.
+ * moving average throughputs.
  *
- * @see EWMA
+ * @see MovingAverages
  */
 public class Meter implements Metered {
-    private static final long TICK_INTERVAL = TimeUnit.SECONDS.toNanos(5);
 
-    private final EWMA m1Rate = EWMA.oneMinuteEWMA();
-    private final EWMA m5Rate = EWMA.fiveMinuteEWMA();
-    private final EWMA m15Rate = EWMA.fifteenMinuteEWMA();
-
-    private final LongAdderAdapter count = LongAdderProxy.create();
+    private final MovingAverages movingAverages;
+    private final LongAdder count = new LongAdder();
     private final long startTime;
-    private final AtomicLong lastTick;
     private final Clock clock;
 
+    /**
+     * Creates a new {@link Meter}.
+     *
+     * @param movingAverages the {@link MovingAverages} implementation to use
+     */
+    public Meter(MovingAverages movingAverages) {
+        this(movingAverages, Clock.defaultClock());
+    }
+
     /**
      * Creates a new {@link Meter}.
      */
@@ -31,12 +35,22 @@ public class Meter implements Metered {
     /**
      * Creates a new {@link Meter}.
      *
-     * @param clock      the clock to use for the meter ticks
+     * @param clock the clock to use for the meter ticks
      */
     public Meter(Clock clock) {
+        this(new ExponentialMovingAverages(clock), clock);
+    }
+
+    /**
+     * Creates a new {@link Meter}.
+     *
+     * @param movingAverages the {@link MovingAverages} implementation to use
+     * @param clock          the clock to use for the meter ticks
+     */
+    public Meter(MovingAverages movingAverages, Clock clock) {
+        this.movingAverages = movingAverages;
         this.clock = clock;
         this.startTime = this.clock.getTick();
-        this.lastTick = new AtomicLong(startTime);
     }
 
     /**
@@ -52,28 +66,9 @@ public class Meter implements Metered {
      * @param n the number of events
      */
     public void mark(long n) {
-        tickIfNecessary();
+        movingAverages.tickIfNecessary();
         count.add(n);
-        m1Rate.update(n);
-        m5Rate.update(n);
-        m15Rate.update(n);
-    }
-
-    private void tickIfNecessary() {
-        final long oldTick = lastTick.get();
-        final long newTick = clock.getTick();
-        final long age = newTick - oldTick;
-        if (age > TICK_INTERVAL) {
-            final long newIntervalStartTick = newTick - age % TICK_INTERVAL;
-            if (lastTick.compareAndSet(oldTick, newIntervalStartTick)) {
-                final long requiredTicks = age / TICK_INTERVAL;
-                for (long i = 0; i < requiredTicks; i++) {
-                    m1Rate.tick();
-                    m5Rate.tick();
-                    m15Rate.tick();
-                }
-            }
-        }
+        movingAverages.update(n);
     }
 
     @Override
@@ -83,14 +78,14 @@ public class Meter implements Metered {
 
     @Override
     public double getFifteenMinuteRate() {
-        tickIfNecessary();
-        return m15Rate.getRate(TimeUnit.SECONDS);
+        movingAverages.tickIfNecessary();
+        return movingAverages.getM15Rate();
     }
 
     @Override
     public double getFiveMinuteRate() {
-        tickIfNecessary();
-        return m5Rate.getRate(TimeUnit.SECONDS);
+        movingAverages.tickIfNecessary();
+        return movingAverages.getM5Rate();
     }
 
     @Override
@@ -98,14 +93,14 @@ public class Meter implements Metered {
         if (getCount() == 0) {
             return 0.0;
         } else {
-            final double elapsed = (clock.getTick() - startTime);
+            final double elapsed = clock.getTick() - startTime;
             return getCount() / elapsed * TimeUnit.SECONDS.toNanos(1);
         }
     }
 
     @Override
     public double getOneMinuteRate() {
-        tickIfNecessary();
-        return m1Rate.getRate(TimeUnit.SECONDS);
+        movingAverages.tickIfNecessary();
+        return movingAverages.getM1Rate();
     }
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Metered.java b/metrics-core/src/main/java/com/codahale/metrics/Metered.java
index 48f2ed0..e3b4283 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Metered.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Metered.java
@@ -1,7 +1,7 @@
 package com.codahale.metrics;
 
 /**
- * An object which maintains mean and exponentially-weighted rate.
+ * An object which maintains mean and moving average rates.
  */
 public interface Metered extends Metric, Counting {
     /**
@@ -9,29 +9,24 @@ public interface Metered extends Metric, Counting {
      *
      * @return the number of events which have been marked
      */
+    @Override
     long getCount();
 
     /**
-     * Returns the fifteen-minute exponentially-weighted moving average rate at which events have
+     * Returns the fifteen-minute moving average rate at which events have
      * occurred since the meter was created.
-     * <p/>
-     * This rate has the same exponential decay factor as the fifteen-minute load average in the
-     * {@code top} Unix command.
      *
-     * @return the fifteen-minute exponentially-weighted moving average rate at which events have
-     *         occurred since the meter was created
+     * @return the fifteen-minute moving average rate at which events have
+     * occurred since the meter was created
      */
     double getFifteenMinuteRate();
 
     /**
-     * Returns the five-minute exponentially-weighted moving average rate at which events have
+     * Returns the five-minute moving average rate at which events have
      * occurred since the meter was created.
-     * <p/>
-     * This rate has the same exponential decay factor as the five-minute load average in the {@code
-     * top} Unix command.
      *
-     * @return the five-minute exponentially-weighted moving average rate at which events have
-     *         occurred since the meter was created
+     * @return the five-minute moving average rate at which events have
+     * occurred since the meter was created
      */
     double getFiveMinuteRate();
 
@@ -43,14 +38,11 @@ public interface Metered extends Metric, Counting {
     double getMeanRate();
 
     /**
-     * Returns the one-minute exponentially-weighted moving average rate at which events have
+     * Returns the one-minute moving average rate at which events have
      * occurred since the meter was created.
-     * <p/>
-     * This rate has the same exponential decay factor as the one-minute load average in the {@code
-     * top} Unix command.
      *
-     * @return the one-minute exponentially-weighted moving average rate at which events have
-     *         occurred since the meter was created
+     * @return the one-minute moving average rate at which events have
+     * occurred since the meter was created
      */
     double getOneMinuteRate();
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/MetricFilter.java b/metrics-core/src/main/java/com/codahale/metrics/MetricFilter.java
index 1239046..bb84b8d 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/MetricFilter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/MetricFilter.java
@@ -7,18 +7,25 @@ public interface MetricFilter {
     /**
      * Matches all metrics, regardless of type or name.
      */
-    MetricFilter ALL = new MetricFilter() {
-        @Override
-        public boolean matches(String name, Metric metric) {
-            return true;
-        }
-    };
+    MetricFilter ALL = (name, metric) -> true;
+
+    static MetricFilter startsWith(String prefix) {
+        return (name, metric) -> name.startsWith(prefix);
+    }
+
+    static MetricFilter endsWith(String suffix) {
+        return (name, metric) -> name.endsWith(suffix);
+    }
+
+    static MetricFilter contains(String substring) {
+        return (name, metric) -> name.contains(substring);
+    }
 
     /**
      * Returns {@code true} if the metric matches the filter; {@code false} otherwise.
      *
-     * @param name      the metric's name
-     * @param metric    the metric
+     * @param name   the metric's name
+     * @param metric the metric
      * @return {@code true} if the metric matches the filter
      */
     boolean matches(String name, Metric metric);
diff --git a/metrics-core/src/main/java/com/codahale/metrics/MetricRegistry.java b/metrics-core/src/main/java/com/codahale/metrics/MetricRegistry.java
index 8662954..528ed2b 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/MetricRegistry.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/MetricRegistry.java
@@ -1,6 +1,12 @@
 package com.codahale.metrics;
 
-import java.util.*;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.CopyOnWriteArrayList;
@@ -12,8 +18,8 @@ public class MetricRegistry implements MetricSet {
     /**
      * Concatenates elements to form a dotted name, eliding any null values or empty strings.
      *
-     * @param name     the first element of the name
-     * @param names    the remaining elements of the name
+     * @param name  the first element of the name
+     * @param names the remaining elements of the name
      * @return {@code name} and {@code names} concatenated by periods
      */
     public static String name(String name, String... names) {
@@ -31,8 +37,8 @@ public class MetricRegistry implements MetricSet {
      * Concatenates a class name and elements to form a dotted name, eliding any null values or
      * empty strings.
      *
-     * @param klass    the first element of the name
-     * @param names    the remaining elements of the name
+     * @param klass the first element of the name
+     * @param names the remaining elements of the name
      * @return {@code klass} and {@code names} concatenated by periods
      */
     public static String name(Class<?> klass, String... names) {
@@ -56,7 +62,7 @@ public class MetricRegistry implements MetricSet {
      */
     public MetricRegistry() {
         this.metrics = buildMap();
-        this.listeners = new CopyOnWriteArrayList<MetricRegistryListener>();
+        this.listeners = new CopyOnWriteArrayList<>();
     }
 
     /**
@@ -67,7 +73,19 @@ public class MetricRegistry implements MetricSet {
      * @return a new {@link ConcurrentMap}
      */
     protected ConcurrentMap<String, Metric> buildMap() {
-        return new ConcurrentHashMap<String, Metric>();
+        return new ConcurrentHashMap<>();
+    }
+
+    /**
+     * Given a {@link Gauge}, registers it under the given name and returns it
+     *
+     * @param name the name of the gauge
+     * @param <T>  the type of the gauge's value
+     * @return the registered {@link Gauge}
+     * @since 4.2.10
+     */
+    public <T> Gauge<T> registerGauge(String name, Gauge<T> metric) throws IllegalArgumentException {
+        return register(name, metric);
     }
 
     /**
@@ -77,11 +95,70 @@ public class MetricRegistry implements MetricSet {
      * @param metric the metric
      * @param <T>    the type of the metric
      * @return {@code metric}
-     * @throws IllegalArgumentException if the name is already registered
+     * @throws IllegalArgumentException if the name is already registered or metric variable is null
      */
     @SuppressWarnings("unchecked")
     public <T extends Metric> T register(String name, T metric) throws IllegalArgumentException {
-        if (metric instanceof MetricSet) {
+
+        if (metric == null) {
+            throw new NullPointerException("metric == null");
+        }
+
+        if (metric instanceof MetricRegistry) {
+            final MetricRegistry childRegistry = (MetricRegistry) metric;
+            final String childName = name;
+            childRegistry.addListener(new MetricRegistryListener() {
+                @Override
+                public void onGaugeAdded(String name, Gauge<?> gauge) {
+                    register(name(childName, name), gauge);
+                }
+
+                @Override
+                public void onGaugeRemoved(String name) {
+                    remove(name(childName, name));
+                }
+
+                @Override
+                public void onCounterAdded(String name, Counter counter) {
+                    register(name(childName, name), counter);
+                }
+
+                @Override
+                public void onCounterRemoved(String name) {
+                    remove(name(childName, name));
+                }
+
+                @Override
+                public void onHistogramAdded(String name, Histogram histogram) {
+                    register(name(childName, name), histogram);
+                }
+
+                @Override
+                public void onHistogramRemoved(String name) {
+                    remove(name(childName, name));
+                }
+
+                @Override
+                public void onMeterAdded(String name, Meter meter) {
+                    register(name(childName, name), meter);
+                }
+
+                @Override
+                public void onMeterRemoved(String name) {
+                    remove(name(childName, name));
+                }
+
+                @Override
+                public void onTimerAdded(String name, Timer timer) {
+                    register(name(childName, name), timer);
+                }
+
+                @Override
+                public void onTimerRemoved(String name) {
+                    remove(name(childName, name));
+                }
+            });
+        } else if (metric instanceof MetricSet) {
             registerAll(name, (MetricSet) metric);
         } else {
             final Metric existing = metrics.putIfAbsent(name, metric);
@@ -97,7 +174,7 @@ public class MetricRegistry implements MetricSet {
     /**
      * Given a metric set, registers them.
      *
-     * @param metrics    a set of metrics
+     * @param metrics a set of metrics
      * @throws IllegalArgumentException if any of the names are already registered
      */
     public void registerAll(MetricSet metrics) throws IllegalArgumentException {
@@ -105,7 +182,7 @@ public class MetricRegistry implements MetricSet {
     }
 
     /**
-     * Return the {@link Counter} registered under this name; or create and register 
+     * Return the {@link Counter} registered under this name; or create and register
      * a new {@link Counter} if none is registered.
      *
      * @param name the name of the metric
@@ -119,7 +196,7 @@ public class MetricRegistry implements MetricSet {
      * Return the {@link Counter} registered under this name; or create and register
      * a new {@link Counter} using the provided MetricSupplier if none is registered.
      *
-     * @param name the name of the metric
+     * @param name     the name of the metric
      * @param supplier a MetricSupplier that can be used to manufacture a counter.
      * @return a new or pre-existing {@link Counter}
      */
@@ -129,6 +206,7 @@ public class MetricRegistry implements MetricSet {
             public Counter newMetric() {
                 return supplier.newMetric();
             }
+
             @Override
             public boolean isInstance(Metric metric) {
                 return Counter.class.isInstance(metric);
@@ -137,7 +215,7 @@ public class MetricRegistry implements MetricSet {
     }
 
     /**
-     * Return the {@link Histogram} registered under this name; or create and register 
+     * Return the {@link Histogram} registered under this name; or create and register
      * a new {@link Histogram} if none is registered.
      *
      * @param name the name of the metric
@@ -151,21 +229,22 @@ public class MetricRegistry implements MetricSet {
      * Return the {@link Histogram} registered under this name; or create and register
      * a new {@link Histogram} using the provided MetricSupplier if none is registered.
      *
-     * @param name the name of the metric
+     * @param name     the name of the metric
      * @param supplier a MetricSupplier that can be used to manufacture a histogram
      * @return a new or pre-existing {@link Histogram}
      */
     public Histogram histogram(String name, final MetricSupplier<Histogram> supplier) {
-      return getOrAdd(name, new MetricBuilder<Histogram>() {
-        @Override
-        public Histogram newMetric() {
-          return supplier.newMetric();
-        }
-        @Override
-        public boolean isInstance(Metric metric) {
-          return Histogram.class.isInstance(metric);
-        }
-      });
+        return getOrAdd(name, new MetricBuilder<Histogram>() {
+            @Override
+            public Histogram newMetric() {
+                return supplier.newMetric();
+            }
+
+            @Override
+            public boolean isInstance(Metric metric) {
+                return Histogram.class.isInstance(metric);
+            }
+        });
     }
 
     /**
@@ -183,7 +262,7 @@ public class MetricRegistry implements MetricSet {
      * Return the {@link Meter} registered under this name; or create and register
      * a new {@link Meter} using the provided MetricSupplier if none is registered.
      *
-     * @param name the name of the metric
+     * @param name     the name of the metric
      * @param supplier a MetricSupplier that can be used to manufacture a Meter
      * @return a new or pre-existing {@link Meter}
      */
@@ -193,6 +272,7 @@ public class MetricRegistry implements MetricSet {
             public Meter newMetric() {
                 return supplier.newMetric();
             }
+
             @Override
             public boolean isInstance(Metric metric) {
                 return Meter.class.isInstance(metric);
@@ -215,7 +295,7 @@ public class MetricRegistry implements MetricSet {
      * Return the {@link Timer} registered under this name; or create and register
      * a new {@link Timer} using the provided MetricSupplier if none is registered.
      *
-     * @param name the name of the metric
+     * @param name     the name of the metric
      * @param supplier a MetricSupplier that can be used to manufacture a Timer
      * @return a new or pre-existing {@link Timer}
      */
@@ -225,6 +305,7 @@ public class MetricRegistry implements MetricSet {
             public Timer newMetric() {
                 return supplier.newMetric();
             }
+
             @Override
             public boolean isInstance(Metric metric) {
                 return Timer.class.isInstance(metric);
@@ -234,18 +315,33 @@ public class MetricRegistry implements MetricSet {
 
     /**
      * Return the {@link Gauge} registered under this name; or create and register
-     * a new {@link Gauge} using the provided MetricSupplier if none is registered.
+     * a new {@link SettableGauge} if none is registered.
      *
      * @param name the name of the metric
+     * @return a pre-existing {@link Gauge} or a new {@link SettableGauge}
+     * @since 4.2
+     */
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    public <T extends Gauge> T gauge(String name) {
+        return (T) getOrAdd(name, MetricBuilder.GAUGES);
+    }
+
+    /**
+     * Return the {@link Gauge} registered under this name; or create and register
+     * a new {@link Gauge} using the provided MetricSupplier if none is registered.
+     *
+     * @param name     the name of the metric
      * @param supplier a MetricSupplier that can be used to manufacture a Gauge
      * @return a new or pre-existing {@link Gauge}
      */
-    public Gauge gauge(String name, final MetricSupplier<Gauge> supplier) {
-        return getOrAdd(name, new MetricBuilder<Gauge>() {
+    @SuppressWarnings("rawtypes")
+    public <T extends Gauge> T gauge(String name, final MetricSupplier<T> supplier) {
+        return getOrAdd(name, new MetricBuilder<T>() {
             @Override
-            public Gauge newMetric() {
+            public T newMetric() {
                 return supplier.newMetric();
             }
+
             @Override
             public boolean isInstance(Metric metric) {
                 return Gauge.class.isInstance(metric);
@@ -254,12 +350,12 @@ public class MetricRegistry implements MetricSet {
     }
 
 
-        /**
-         * Removes the metric with the given name.
-         *
-         * @param name the name of the metric
-         * @return whether or not the metric was removed
-         */
+    /**
+     * Removes the metric with the given name.
+     *
+     * @param name the name of the metric
+     * @return whether or not the metric was removed
+     */
     public boolean remove(String name) {
         final Metric metric = metrics.remove(name);
         if (metric != null) {
@@ -285,7 +381,7 @@ public class MetricRegistry implements MetricSet {
     /**
      * Adds a {@link MetricRegistryListener} to a collection of listeners that will be notified on
      * metric creation.  Listeners will be notified in the order in which they are added.
-     * <p/>
+     * <p>
      * <b>N.B.:</b> The listener will be notified of all existing metrics when it first registers.
      *
      * @param listener the listener that will be notified
@@ -313,7 +409,7 @@ public class MetricRegistry implements MetricSet {
      * @return the names of all the metrics
      */
     public SortedSet<String> getNames() {
-        return Collections.unmodifiableSortedSet(new TreeSet<String>(metrics.keySet()));
+        return Collections.unmodifiableSortedSet(new TreeSet<>(metrics.keySet()));
     }
 
     /**
@@ -321,6 +417,7 @@ public class MetricRegistry implements MetricSet {
      *
      * @return all the gauges in the registry
      */
+    @SuppressWarnings("rawtypes")
     public SortedMap<String, Gauge> getGauges() {
         return getGauges(MetricFilter.ALL);
     }
@@ -328,9 +425,10 @@ public class MetricRegistry implements MetricSet {
     /**
      * Returns a map of all the gauges in the registry and their names which match the given filter.
      *
-     * @param filter    the metric filter to match
+     * @param filter the metric filter to match
      * @return all the gauges in the registry
      */
+    @SuppressWarnings("rawtypes")
     public SortedMap<String, Gauge> getGauges(MetricFilter filter) {
         return getMetrics(Gauge.class, filter);
     }
@@ -348,7 +446,7 @@ public class MetricRegistry implements MetricSet {
      * Returns a map of all the counters in the registry and their names which match the given
      * filter.
      *
-     * @param filter    the metric filter to match
+     * @param filter the metric filter to match
      * @return all the counters in the registry
      */
     public SortedMap<String, Counter> getCounters(MetricFilter filter) {
@@ -368,7 +466,7 @@ public class MetricRegistry implements MetricSet {
      * Returns a map of all the histograms in the registry and their names which match the given
      * filter.
      *
-     * @param filter    the metric filter to match
+     * @param filter the metric filter to match
      * @return all the histograms in the registry
      */
     public SortedMap<String, Histogram> getHistograms(MetricFilter filter) {
@@ -387,7 +485,7 @@ public class MetricRegistry implements MetricSet {
     /**
      * Returns a map of all the meters in the registry and their names which match the given filter.
      *
-     * @param filter    the metric filter to match
+     * @param filter the metric filter to match
      * @return all the meters in the registry
      */
     public SortedMap<String, Meter> getMeters(MetricFilter filter) {
@@ -406,7 +504,7 @@ public class MetricRegistry implements MetricSet {
     /**
      * Returns a map of all the timers in the registry and their names which match the given filter.
      *
-     * @param filter    the metric filter to match
+     * @param filter the metric filter to match
      * @return all the timers in the registry
      */
     public SortedMap<String, Timer> getTimers(MetricFilter filter) {
@@ -433,10 +531,10 @@ public class MetricRegistry implements MetricSet {
 
     @SuppressWarnings("unchecked")
     private <T extends Metric> SortedMap<String, T> getMetrics(Class<T> klass, MetricFilter filter) {
-        final TreeMap<String, T> timers = new TreeMap<String, T>();
+        final TreeMap<String, T> timers = new TreeMap<>();
         for (Map.Entry<String, Metric> entry : metrics.entrySet()) {
             if (klass.isInstance(entry.getValue()) && filter.matches(entry.getKey(),
-                                                                     entry.getValue())) {
+                    entry.getValue())) {
                 timers.put(entry.getKey(), (T) entry.getValue());
             }
         }
@@ -487,7 +585,14 @@ public class MetricRegistry implements MetricSet {
         }
     }
 
-    private void registerAll(String prefix, MetricSet metrics) throws IllegalArgumentException {
+    /**
+     * Given a metric set, registers them with the given prefix prepended to their names.
+     *
+     * @param prefix a name prefix
+     * @param metrics a set of metrics
+     * @throws IllegalArgumentException if any of the names are already registered
+     */
+    public void registerAll(String prefix, MetricSet metrics) throws IllegalArgumentException {
         for (Map.Entry<String, Metric> entry : metrics.getMetrics().entrySet()) {
             if (entry.getValue() instanceof MetricSet) {
                 registerAll(name(prefix, entry.getKey()), (MetricSet) entry.getValue());
@@ -502,8 +607,9 @@ public class MetricRegistry implements MetricSet {
         return Collections.unmodifiableMap(metrics);
     }
 
+    @FunctionalInterface
     public interface MetricSupplier<T extends Metric> {
-      T newMetric();
+        T newMetric();
     }
 
     /**
@@ -558,6 +664,19 @@ public class MetricRegistry implements MetricSet {
             }
         };
 
+        @SuppressWarnings("rawtypes")
+        MetricBuilder<Gauge> GAUGES = new MetricBuilder<Gauge>() {
+            @Override
+            public Gauge newMetric() {
+                return new DefaultSettableGauge<>();
+            }
+
+            @Override
+            public boolean isInstance(Metric metric) {
+                return Gauge.class.isInstance(metric);
+            }
+        };
+
         T newMetric();
 
         boolean isInstance(Metric metric);
diff --git a/metrics-core/src/main/java/com/codahale/metrics/MovingAverages.java b/metrics-core/src/main/java/com/codahale/metrics/MovingAverages.java
new file mode 100644
index 0000000..a0aee40
--- /dev/null
+++ b/metrics-core/src/main/java/com/codahale/metrics/MovingAverages.java
@@ -0,0 +1,48 @@
+package com.codahale.metrics;
+
+/**
+ * A triple of moving averages (one-, five-, and fifteen-minute
+ * moving average) as needed by {@link Meter}.
+ * <p>
+ * Included implementations are:
+ * <ul>
+ * <li>{@link ExponentialMovingAverages} exponential decaying average similar to the {@code top} Unix command.
+ * <li>{@link SlidingTimeWindowMovingAverages} simple (unweighted) moving average
+ * </ul>
+ */
+public interface MovingAverages {
+
+    /**
+     * Tick the internal clock of the MovingAverages implementation if needed
+     * (according to the internal ticking interval)
+     */
+    void tickIfNecessary();
+
+    /**
+     * Update all three moving averages with n events having occurred since the last update.
+     *
+     * @param n
+     */
+    void update(long n);
+
+    /**
+     * Returns the one-minute moving average rate
+     *
+     * @return the one-minute moving average rate
+     */
+    double getM1Rate();
+
+    /**
+     * Returns the five-minute moving average rate
+     *
+     * @return the five-minute moving average rate
+     */
+    double getM5Rate();
+
+    /**
+     * Returns the fifteen-minute moving average rate
+     *
+     * @return the fifteen-minute moving average rate
+     */
+    double getM15Rate();
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/NoopMetricRegistry.java b/metrics-core/src/main/java/com/codahale/metrics/NoopMetricRegistry.java
new file mode 100644
index 0000000..db65193
--- /dev/null
+++ b/metrics-core/src/main/java/com/codahale/metrics/NoopMetricRegistry.java
@@ -0,0 +1,793 @@
+package com.codahale.metrics;
+
+import java.io.OutputStream;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+/**
+ * A registry of metric instances which never creates or registers any metrics and returns no-op implementations of any metric type.
+ *
+ * @since 4.1.17
+ */
+public final class NoopMetricRegistry extends MetricRegistry {
+    private static final EmptyConcurrentMap<String, Metric> EMPTY_CONCURRENT_MAP = new EmptyConcurrentMap<>();
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected ConcurrentMap<String, Metric> buildMap() {
+        return EMPTY_CONCURRENT_MAP;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public <T extends Metric> T register(String name, T metric) throws IllegalArgumentException {
+        if (metric == null) {
+            throw new NullPointerException("metric == null");
+        }
+        return metric;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void registerAll(MetricSet metrics) throws IllegalArgumentException {
+        // NOP
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Counter counter(String name) {
+        return NoopCounter.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Counter counter(String name, MetricSupplier<Counter> supplier) {
+        return NoopCounter.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Histogram histogram(String name) {
+        return NoopHistogram.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Histogram histogram(String name, MetricSupplier<Histogram> supplier) {
+        return NoopHistogram.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Meter meter(String name) {
+        return NoopMeter.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Meter meter(String name, MetricSupplier<Meter> supplier) {
+        return NoopMeter.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Timer timer(String name) {
+        return NoopTimer.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Timer timer(String name, MetricSupplier<Timer> supplier) {
+        return NoopTimer.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @since 4.2
+     */
+    @Override
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    public <T extends Gauge> T gauge(String name) {
+        return (T) NoopGauge.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    public <T extends Gauge> T gauge(String name, MetricSupplier<T> supplier) {
+        return (T) NoopGauge.INSTANCE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean remove(String name) {
+        return false;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void removeMatching(MetricFilter filter) {
+        // NOP
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void addListener(MetricRegistryListener listener) {
+        // NOP
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void removeListener(MetricRegistryListener listener) {
+        // NOP
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedSet<String> getNames() {
+        return Collections.emptySortedSet();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    @SuppressWarnings("rawtypes")
+    public SortedMap<String, Gauge> getGauges() {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    @SuppressWarnings("rawtypes")
+    public SortedMap<String, Gauge> getGauges(MetricFilter filter) {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Counter> getCounters() {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Counter> getCounters(MetricFilter filter) {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Histogram> getHistograms() {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Histogram> getHistograms(MetricFilter filter) {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Meter> getMeters() {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Meter> getMeters(MetricFilter filter) {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Timer> getTimers() {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public SortedMap<String, Timer> getTimers(MetricFilter filter) {
+        return Collections.emptySortedMap();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void registerAll(String prefix, MetricSet metrics) throws IllegalArgumentException {
+        // NOP
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Map<String, Metric> getMetrics() {
+        return Collections.emptyMap();
+    }
+
+    static final class NoopGauge<T> implements Gauge<T> {
+        private static final NoopGauge<?> INSTANCE = new NoopGauge<>();
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public T getValue() {
+            return null;
+        }
+    }
+
+    private static final class EmptySnapshot extends Snapshot {
+        private static final EmptySnapshot INSTANCE = new EmptySnapshot();
+        private static final long[] EMPTY_LONG_ARRAY = new long[0];
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getValue(double quantile) {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public long[] getValues() {
+            return EMPTY_LONG_ARRAY;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public int size() {
+            return 0;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public long getMax() {
+            return 0L;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getMean() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public long getMin() {
+            return 0L;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getStdDev() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void dump(OutputStream output) {
+            // NOP
+        }
+    }
+
+    static final class NoopTimer extends Timer {
+        private static final NoopTimer INSTANCE = new NoopTimer();
+        private static final Timer.Context CONTEXT = new NoopTimer.Context();
+
+        private static class Context extends Timer.Context {
+            private static final Clock CLOCK = new Clock() {
+                /**
+                 * {@inheritDoc}
+                 */
+                @Override
+                public long getTick() {
+                    return 0L;
+                }
+
+                /**
+                 * {@inheritDoc}
+                 */
+                @Override
+                public long getTime() {
+                    return 0L;
+                }
+            };
+
+            private Context() {
+                super(INSTANCE, CLOCK);
+            }
+
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            public long stop() {
+                return 0L;
+            }
+
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            public void close() {
+                // NOP
+            }
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void update(long duration, TimeUnit unit) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void update(Duration duration) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public <T> T time(Callable<T> event) throws Exception {
+            return event.call();
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public <T> T timeSupplier(Supplier<T> event) {
+            return event.get();
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void time(Runnable event) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Timer.Context time() {
+            return CONTEXT;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public long getCount() {
+            return 0L;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getFifteenMinuteRate() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getFiveMinuteRate() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getMeanRate() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getOneMinuteRate() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Snapshot getSnapshot() {
+            return EmptySnapshot.INSTANCE;
+        }
+    }
+
+    static final class NoopHistogram extends Histogram {
+        private static final NoopHistogram INSTANCE = new NoopHistogram();
+        private static final Reservoir EMPTY_RESERVOIR = new Reservoir() {
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            public int size() {
+                return 0;
+            }
+
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            public void update(long value) {
+                // NOP
+            }
+
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            public Snapshot getSnapshot() {
+                return EmptySnapshot.INSTANCE;
+            }
+        };
+
+        private NoopHistogram() {
+            super(EMPTY_RESERVOIR);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void update(int value) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void update(long value) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public long getCount() {
+            return 0L;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Snapshot getSnapshot() {
+            return EmptySnapshot.INSTANCE;
+        }
+    }
+
+    static final class NoopCounter extends Counter {
+        private static final NoopCounter INSTANCE = new NoopCounter();
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void inc() {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void inc(long n) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void dec() {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void dec(long n) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public long getCount() {
+            return 0L;
+        }
+    }
+
+    static final class NoopMeter extends Meter {
+        private static final NoopMeter INSTANCE = new NoopMeter();
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void mark() {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void mark(long n) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public long getCount() {
+            return 0L;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getFifteenMinuteRate() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getFiveMinuteRate() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getMeanRate() {
+            return 0D;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public double getOneMinuteRate() {
+            return 0D;
+        }
+    }
+
+    private static final class EmptyConcurrentMap<K, V> implements ConcurrentMap<K, V> {
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public V putIfAbsent(K key, V value) {
+            return null;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean remove(Object key, Object value) {
+            return false;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean replace(K key, V oldValue, V newValue) {
+            return false;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public V replace(K key, V value) {
+            return null;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public int size() {
+            return 0;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean isEmpty() {
+            return true;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean containsKey(Object key) {
+            return false;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean containsValue(Object value) {
+            return false;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public V get(Object key) {
+            return null;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public V put(K key, V value) {
+            return null;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public V remove(Object key) {
+            return null;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void putAll(Map<? extends K, ? extends V> m) {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void clear() {
+            // NOP
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Set<K> keySet() {
+            return Collections.emptySet();
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Collection<V> values() {
+            return Collections.emptySet();
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public Set<Entry<K, V>> entrySet() {
+            return Collections.emptySet();
+        }
+    }
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ObjectNameFactory.java b/metrics-core/src/main/java/com/codahale/metrics/ObjectNameFactory.java
deleted file mode 100644
index 5b28715..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/ObjectNameFactory.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.codahale.metrics;
-
-import javax.management.ObjectName;
-
-public interface ObjectNameFactory {
-
-	ObjectName createName(String type, String domain, String name);
-}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/RatioGauge.java b/metrics-core/src/main/java/com/codahale/metrics/RatioGauge.java
index 182155f..b6407ab 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/RatioGauge.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/RatioGauge.java
@@ -5,7 +5,7 @@ import static java.lang.Double.isNaN;
 
 /**
  * A gauge which measures the ratio of one value to another.
- * <p/>
+ * <p>
  * If the denominator is zero, not a number, or infinite, the resulting ratio is not a number.
  */
 public abstract class RatioGauge implements Gauge<Double> {
@@ -16,8 +16,8 @@ public abstract class RatioGauge implements Gauge<Double> {
         /**
          * Creates a new ratio with the given numerator and denominator.
          *
-         * @param numerator      the numerator of the ratio
-         * @param denominator    the denominator of the ratio
+         * @param numerator   the numerator of the ratio
+         * @param denominator the denominator of the ratio
          * @return {@code numerator:denominator}
          */
         public static Ratio of(double numerator, double denominator) {
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Reporter.java b/metrics-core/src/main/java/com/codahale/metrics/Reporter.java
index a429408..cbee18a 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Reporter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Reporter.java
@@ -1,8 +1,10 @@
 package com.codahale.metrics;
 
+import java.io.Closeable;
+
 /*
  * A tag interface to indicate that a class is a Reporter.
  */
-public interface Reporter {
+public interface Reporter extends Closeable {
 
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ScheduledReporter.java b/metrics-core/src/main/java/com/codahale/metrics/ScheduledReporter.java
index 32e6757..4723cdf 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/ScheduledReporter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/ScheduledReporter.java
@@ -8,13 +8,12 @@ import java.util.Collections;
 import java.util.Locale;
 import java.util.Set;
 import java.util.SortedMap;
-import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -71,11 +70,11 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
     /**
      * Creates a new {@link ScheduledReporter} instance.
      *
-     * @param registry the {@link com.codahale.metrics.MetricRegistry} containing the metrics this
-     *                 reporter will report
-     * @param name     the reporter's name
-     * @param filter   the filter for which metrics to report
-     * @param rateUnit a unit of time
+     * @param registry     the {@link com.codahale.metrics.MetricRegistry} containing the metrics this
+     *                     reporter will report
+     * @param name         the reporter's name
+     * @param filter       the filter for which metrics to report
+     * @param rateUnit     a unit of time
      * @param durationUnit a unit of time
      */
     protected ScheduledReporter(MetricRegistry registry,
@@ -83,7 +82,7 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
                                 MetricFilter filter,
                                 TimeUnit rateUnit,
                                 TimeUnit durationUnit) {
-		this(registry, name, filter, rateUnit, durationUnit, createDefaultExecutor(name));
+        this(registry, name, filter, rateUnit, durationUnit, createDefaultExecutor(name));
     }
 
     /**
@@ -107,11 +106,11 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
     /**
      * Creates a new {@link ScheduledReporter} instance.
      *
-     * @param registry the {@link com.codahale.metrics.MetricRegistry} containing the metrics this
-     *                 reporter will report
-     * @param name     the reporter's name
-     * @param filter   the filter for which metrics to report
-     * @param executor the executor to use while scheduling reporting of metrics.
+     * @param registry               the {@link com.codahale.metrics.MetricRegistry} containing the metrics this
+     *                               reporter will report
+     * @param name                   the reporter's name
+     * @param filter                 the filter for which metrics to report
+     * @param executor               the executor to use while scheduling reporting of metrics.
      * @param shutdownExecutorOnStop if true, then executor will be stopped in same time with this reporter
      */
     protected ScheduledReporter(MetricRegistry registry,
@@ -121,8 +120,7 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
                                 TimeUnit durationUnit,
                                 ScheduledExecutorService executor,
                                 boolean shutdownExecutorOnStop) {
-       this(registry, name, filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop,
-               Collections.<MetricAttribute>emptySet());
+        this(registry, name, filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop, Collections.emptySet());
     }
 
     protected ScheduledReporter(MetricRegistry registry,
@@ -133,16 +131,21 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
                                 ScheduledExecutorService executor,
                                 boolean shutdownExecutorOnStop,
                                 Set<MetricAttribute> disabledMetricAttributes) {
+
+        if (registry == null) {
+            throw new NullPointerException("registry == null");
+        }
+
         this.registry = registry;
         this.filter = filter;
-        this.executor = executor == null? createDefaultExecutor(name) : executor;
+        this.executor = executor == null ? createDefaultExecutor(name) : executor;
         this.shutdownExecutorOnStop = shutdownExecutorOnStop;
         this.rateFactor = rateUnit.toSeconds(1);
         this.rateUnit = calculateRateUnit(rateUnit);
         this.durationFactor = durationUnit.toNanos(1);
         this.durationUnit = durationUnit.toString().toLowerCase(Locale.US);
         this.disabledMetricAttributes = disabledMetricAttributes != null ? disabledMetricAttributes :
-                Collections.<MetricAttribute>emptySet();
+                Collections.emptySet();
     }
 
     /**
@@ -152,7 +155,7 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
      * @param unit   the unit for {@code period}
      */
     public void start(long period, TimeUnit unit) {
-       start(period, period, unit);
+        start(period, period, unit);
     }
 
     /**
@@ -164,7 +167,30 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
             throw new IllegalArgumentException("Reporter already started");
         }
 
-        this.scheduledFuture = executor.scheduleAtFixedRate(runnable, initialDelay, period, unit);
+        this.scheduledFuture = getScheduledFuture(initialDelay, period, unit, runnable);
+    }
+
+
+    /**
+     * Schedule the task, and return a future.
+     *
+     * @deprecated Use {@link #getScheduledFuture(long, long, TimeUnit, Runnable, ScheduledExecutorService)} instead.
+     */
+    @SuppressWarnings("DeprecatedIsStillUsed")
+    @Deprecated
+    protected ScheduledFuture<?> getScheduledFuture(long initialDelay, long period, TimeUnit unit, Runnable runnable) {
+        return getScheduledFuture(initialDelay, period, unit, runnable, this.executor);
+    }
+
+    /**
+     * Schedule the task, and return a future.
+     * The current implementation uses scheduleWithFixedDelay, replacing scheduleWithFixedRate. This avoids queueing issues, but may
+     * cause some reporters to skip metrics, as scheduleWithFixedDelay introduces a growing delta from the original start point.
+     *
+     * Overriding this in a subclass to revert to the old behavior is permitted.
+     */
+    protected ScheduledFuture<?> getScheduledFuture(long initialDelay, long period, TimeUnit unit, Runnable runnable, ScheduledExecutorService executor) {
+        return executor.scheduleWithFixedDelay(runnable, initialDelay, period, unit);
     }
 
     /**
@@ -172,36 +198,42 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
      *
      * @param initialDelay the time to delay the first execution
      * @param period       the amount of time between polls
-     * @param unit         the unit for {@code period}
+     * @param unit         the unit for {@code period} and {@code initialDelay}
      */
     synchronized public void start(long initialDelay, long period, TimeUnit unit) {
-        start(initialDelay, period, unit, new Runnable() {
-            @Override
-            public void run() {
-                try {
-                    report();
-                } catch (Throwable ex) {
-                    LOG.error("Exception thrown from {}#report. Exception was suppressed.", ScheduledReporter.this.getClass().getSimpleName(), ex);
-                }
+        start(initialDelay, period, unit, () -> {
+            try {
+                report();
+            } catch (Throwable ex) {
+                LOG.error("Exception thrown from {}#report. Exception was suppressed.", ScheduledReporter.this.getClass().getSimpleName(), ex);
             }
         });
     }
 
     /**
      * Stops the reporter and if shutdownExecutorOnStop is true then shuts down its thread of execution.
-     *
+     * <p>
      * Uses the shutdown pattern from http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ExecutorService.html
      */
     public void stop() {
         if (shutdownExecutorOnStop) {
             executor.shutdown(); // Disable new tasks from being submitted
+        }
+
+        try {
+            report(); // Report metrics one last time
+        } catch (Exception e) {
+            LOG.warn("Final reporting of metrics failed.", e);
+        }
+
+        if (shutdownExecutorOnStop) {
             try {
                 // Wait a while for existing tasks to terminate
                 if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
                     executor.shutdownNow(); // Cancel currently executing tasks
                     // Wait a while for tasks to respond to being cancelled
                     if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
-                        System.err.println(getClass().getSimpleName() + ": ScheduledExecutorService did not terminate");
+                        LOG.warn("ScheduledExecutorService did not terminate.");
                     }
                 }
             } catch (InterruptedException ie) {
@@ -211,20 +243,22 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
                 Thread.currentThread().interrupt();
             }
         } else {
-            // The external manager(like JEE container) responsible for lifecycle of executor
-            synchronized (this) {
-                if (this.scheduledFuture == null) {
-                    // was never started
-                    return;
-                }
-                if (this.scheduledFuture.isCancelled()) {
-                    // already cancelled
-                    return;
-                }
-                // just cancel the scheduledFuture and exit
-                this.scheduledFuture.cancel(false);
-            }
+            // The external manager (like JEE container) responsible for lifecycle of executor
+            cancelScheduledFuture();
+        }
+    }
+
+    private synchronized void cancelScheduledFuture() {
+        if (this.scheduledFuture == null) {
+            // was never started
+            return;
+        }
+        if (this.scheduledFuture.isCancelled()) {
+            // already cancelled
+            return;
         }
+        // just cancel the scheduledFuture and exit
+        this.scheduledFuture.cancel(false);
     }
 
     /**
@@ -257,6 +291,7 @@ public abstract class ScheduledReporter implements Closeable, Reporter {
      * @param meters     all of the meters in the registry
      * @param timers     all of the timers in the registry
      */
+    @SuppressWarnings("rawtypes")
     public abstract void report(SortedMap<String, Gauge> gauges,
                                 SortedMap<String, Counter> counters,
                                 SortedMap<String, Histogram> histograms,
diff --git a/metrics-core/src/main/java/com/codahale/metrics/SettableGauge.java b/metrics-core/src/main/java/com/codahale/metrics/SettableGauge.java
new file mode 100644
index 0000000..68f18a8
--- /dev/null
+++ b/metrics-core/src/main/java/com/codahale/metrics/SettableGauge.java
@@ -0,0 +1,14 @@
+package com.codahale.metrics;
+
+/**
+ * <p>
+ * Similar to {@link Gauge}, but metric value is updated via calling {@link #setValue(T)} instead.
+ * See {@link DefaultSettableGauge}.
+ * </p>
+ */
+public interface SettableGauge<T> extends Gauge<T> {
+    /**
+     * Set the metric to a new value.
+     */
+    void setValue(T value);
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/SharedMetricRegistries.java b/metrics-core/src/main/java/com/codahale/metrics/SharedMetricRegistries.java
index 2fe00ae..91bee56 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/SharedMetricRegistries.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/SharedMetricRegistries.java
@@ -10,9 +10,9 @@ import java.util.concurrent.atomic.AtomicReference;
  */
 public class SharedMetricRegistries {
     private static final ConcurrentMap<String, MetricRegistry> REGISTRIES =
-            new ConcurrentHashMap<String, MetricRegistry>();
+            new ConcurrentHashMap<>();
 
-    private static AtomicReference<String> defaultRegistryName = new AtomicReference<String>();
+    private static AtomicReference<String> defaultRegistryName = new AtomicReference<>();
 
     /* Visible for testing */
     static void setDefaultRegistryName(AtomicReference<String> defaultRegistryName) {
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Slf4jReporter.java b/metrics-core/src/main/java/com/codahale/metrics/Slf4jReporter.java
index 317517c..63c0bdc 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Slf4jReporter.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Slf4jReporter.java
@@ -4,10 +4,29 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.Marker;
 
+import java.util.Collections;
 import java.util.Map.Entry;
+import java.util.Set;
 import java.util.SortedMap;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static com.codahale.metrics.MetricAttribute.COUNT;
+import static com.codahale.metrics.MetricAttribute.M15_RATE;
+import static com.codahale.metrics.MetricAttribute.M1_RATE;
+import static com.codahale.metrics.MetricAttribute.M5_RATE;
+import static com.codahale.metrics.MetricAttribute.MAX;
+import static com.codahale.metrics.MetricAttribute.MEAN;
+import static com.codahale.metrics.MetricAttribute.MEAN_RATE;
+import static com.codahale.metrics.MetricAttribute.MIN;
+import static com.codahale.metrics.MetricAttribute.P50;
+import static com.codahale.metrics.MetricAttribute.P75;
+import static com.codahale.metrics.MetricAttribute.P95;
+import static com.codahale.metrics.MetricAttribute.P98;
+import static com.codahale.metrics.MetricAttribute.P99;
+import static com.codahale.metrics.MetricAttribute.P999;
+import static com.codahale.metrics.MetricAttribute.STDDEV;
 
 /**
  * A reporter class for logging metrics values to a SLF4J {@link Logger} periodically, similar to
@@ -26,7 +45,7 @@ public class Slf4jReporter extends ScheduledReporter {
         return new Builder(registry);
     }
 
-    public enum LoggingLevel {TRACE, DEBUG, INFO, WARN, ERROR}
+    public enum LoggingLevel { TRACE, DEBUG, INFO, WARN, ERROR }
 
     /**
      * A builder for {@link Slf4jReporter} instances. Defaults to logging to {@code metrics}, not
@@ -44,6 +63,7 @@ public class Slf4jReporter extends ScheduledReporter {
         private MetricFilter filter;
         private ScheduledExecutorService executor;
         private boolean shutdownExecutorOnStop;
+        private Set<MetricAttribute> disabledMetricAttributes;
 
         private Builder(MetricRegistry registry) {
             this.registry = registry;
@@ -56,6 +76,7 @@ public class Slf4jReporter extends ScheduledReporter {
             this.loggingLevel = LoggingLevel.INFO;
             this.executor = null;
             this.shutdownExecutorOnStop = true;
+            this.disabledMetricAttributes = Collections.emptySet();
         }
 
         /**
@@ -161,6 +182,18 @@ public class Slf4jReporter extends ScheduledReporter {
             return this;
         }
 
+        /**
+         * Don't report the passed metric attributes for all metrics (e.g. "p999", "stddev" or "m15").
+         * See {@link MetricAttribute}.
+         *
+         * @param disabledMetricAttributes a set of {@link MetricAttribute}
+         * @return {@code this}
+         */
+        public Builder disabledMetricAttributes(Set<MetricAttribute> disabledMetricAttributes) {
+            this.disabledMetricAttributes = disabledMetricAttributes;
+            return this;
+        }
+
         /**
          * Builds a {@link Slf4jReporter} with the given properties.
          *
@@ -186,7 +219,8 @@ public class Slf4jReporter extends ScheduledReporter {
                     loggerProxy = new DebugLoggerProxy(logger);
                     break;
             }
-            return new Slf4jReporter(registry, loggerProxy, marker, prefix, rateUnit, durationUnit, filter, executor, shutdownExecutorOnStop);
+            return new Slf4jReporter(registry, loggerProxy, marker, prefix, rateUnit, durationUnit, filter, executor,
+                    shutdownExecutorOnStop, disabledMetricAttributes);
         }
     }
 
@@ -202,108 +236,174 @@ public class Slf4jReporter extends ScheduledReporter {
                           TimeUnit durationUnit,
                           MetricFilter filter,
                           ScheduledExecutorService executor,
-                          boolean shutdownExecutorOnStop) {
-        super(registry, "logger-reporter", filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop);
+                          boolean shutdownExecutorOnStop,
+                          Set<MetricAttribute> disabledMetricAttributes) {
+        super(registry, "logger-reporter", filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop,
+                disabledMetricAttributes);
         this.loggerProxy = loggerProxy;
         this.marker = marker;
         this.prefix = prefix;
     }
 
     @Override
+    @SuppressWarnings("rawtypes")
     public void report(SortedMap<String, Gauge> gauges,
                        SortedMap<String, Counter> counters,
                        SortedMap<String, Histogram> histograms,
                        SortedMap<String, Meter> meters,
                        SortedMap<String, Timer> timers) {
         if (loggerProxy.isEnabled(marker)) {
+            StringBuilder b = new StringBuilder();
             for (Entry<String, Gauge> entry : gauges.entrySet()) {
-                logGauge(entry.getKey(), entry.getValue());
+                logGauge(b, entry.getKey(), entry.getValue());
             }
 
             for (Entry<String, Counter> entry : counters.entrySet()) {
-                logCounter(entry.getKey(), entry.getValue());
+                logCounter(b, entry.getKey(), entry.getValue());
             }
 
             for (Entry<String, Histogram> entry : histograms.entrySet()) {
-                logHistogram(entry.getKey(), entry.getValue());
+                logHistogram(b, entry.getKey(), entry.getValue());
             }
 
             for (Entry<String, Meter> entry : meters.entrySet()) {
-                logMeter(entry.getKey(), entry.getValue());
+                logMeter(b, entry.getKey(), entry.getValue());
             }
 
             for (Entry<String, Timer> entry : timers.entrySet()) {
-                logTimer(entry.getKey(), entry.getValue());
+                logTimer(b, entry.getKey(), entry.getValue());
             }
         }
     }
 
-    private void logTimer(String name, Timer timer) {
+    private void logTimer(StringBuilder b, String name, Timer timer) {
         final Snapshot snapshot = timer.getSnapshot();
-        loggerProxy.log(marker,
-                "type={}, name={}, count={}, min={}, max={}, mean={}, stddev={}, median={}, " +
-                        "p75={}, p95={}, p98={}, p99={}, p999={}, mean_rate={}, m1={}, m5={}, " +
-                        "m15={}, rate_unit={}, duration_unit={}",
-                "TIMER",
-                prefix(name),
-                timer.getCount(),
-                convertDuration(snapshot.getMin()),
-                convertDuration(snapshot.getMax()),
-                convertDuration(snapshot.getMean()),
-                convertDuration(snapshot.getStdDev()),
-                convertDuration(snapshot.getMedian()),
-                convertDuration(snapshot.get75thPercentile()),
-                convertDuration(snapshot.get95thPercentile()),
-                convertDuration(snapshot.get98thPercentile()),
-                convertDuration(snapshot.get99thPercentile()),
-                convertDuration(snapshot.get999thPercentile()),
-                convertRate(timer.getMeanRate()),
-                convertRate(timer.getOneMinuteRate()),
-                convertRate(timer.getFiveMinuteRate()),
-                convertRate(timer.getFifteenMinuteRate()),
-                getRateUnit(),
-                getDurationUnit());
+        b.setLength(0);
+        b.append("type=TIMER");
+        append(b, "name", prefix(name));
+        appendCountIfEnabled(b, timer);
+        appendLongDurationIfEnabled(b, MIN, snapshot::getMin);
+        appendLongDurationIfEnabled(b, MAX, snapshot::getMax);
+        appendDoubleDurationIfEnabled(b, MEAN, snapshot::getMean);
+        appendDoubleDurationIfEnabled(b, STDDEV, snapshot::getStdDev);
+        appendDoubleDurationIfEnabled(b, P50, snapshot::getMedian);
+        appendDoubleDurationIfEnabled(b, P75, snapshot::get75thPercentile);
+        appendDoubleDurationIfEnabled(b, P95, snapshot::get95thPercentile);
+        appendDoubleDurationIfEnabled(b, P98, snapshot::get98thPercentile);
+        appendDoubleDurationIfEnabled(b, P99, snapshot::get99thPercentile);
+        appendDoubleDurationIfEnabled(b, P999, snapshot::get999thPercentile);
+        appendMetered(b, timer);
+        append(b, "rate_unit", getRateUnit());
+        append(b, "duration_unit", getDurationUnit());
+        loggerProxy.log(marker, b.toString());
     }
 
-    private void logMeter(String name, Meter meter) {
-        loggerProxy.log(marker,
-                "type={}, name={}, count={}, mean_rate={}, m1={}, m5={}, m15={}, rate_unit={}",
-                "METER",
-                prefix(name),
-                meter.getCount(),
-                convertRate(meter.getMeanRate()),
-                convertRate(meter.getOneMinuteRate()),
-                convertRate(meter.getFiveMinuteRate()),
-                convertRate(meter.getFifteenMinuteRate()),
-                getRateUnit());
+    private void logMeter(StringBuilder b, String name, Meter meter) {
+        b.setLength(0);
+        b.append("type=METER");
+        append(b, "name", prefix(name));
+        appendCountIfEnabled(b, meter);
+        appendMetered(b, meter);
+        append(b, "rate_unit", getRateUnit());
+        loggerProxy.log(marker, b.toString());
     }
 
-    private void logHistogram(String name, Histogram histogram) {
+    private void logHistogram(StringBuilder b, String name, Histogram histogram) {
         final Snapshot snapshot = histogram.getSnapshot();
-        loggerProxy.log(marker,
-                "type={}, name={}, count={}, min={}, max={}, mean={}, stddev={}, " +
-                        "median={}, p75={}, p95={}, p98={}, p99={}, p999={}",
-                "HISTOGRAM",
-                prefix(name),
-                histogram.getCount(),
-                snapshot.getMin(),
-                snapshot.getMax(),
-                snapshot.getMean(),
-                snapshot.getStdDev(),
-                snapshot.getMedian(),
-                snapshot.get75thPercentile(),
-                snapshot.get95thPercentile(),
-                snapshot.get98thPercentile(),
-                snapshot.get99thPercentile(),
-                snapshot.get999thPercentile());
+        b.setLength(0);
+        b.append("type=HISTOGRAM");
+        append(b, "name", prefix(name));
+        appendCountIfEnabled(b, histogram);
+        appendLongIfEnabled(b, MIN, snapshot::getMin);
+        appendLongIfEnabled(b, MAX, snapshot::getMax);
+        appendDoubleIfEnabled(b, MEAN, snapshot::getMean);
+        appendDoubleIfEnabled(b, STDDEV, snapshot::getStdDev);
+        appendDoubleIfEnabled(b, P50, snapshot::getMedian);
+        appendDoubleIfEnabled(b, P75, snapshot::get75thPercentile);
+        appendDoubleIfEnabled(b, P95, snapshot::get95thPercentile);
+        appendDoubleIfEnabled(b, P98, snapshot::get98thPercentile);
+        appendDoubleIfEnabled(b, P99, snapshot::get99thPercentile);
+        appendDoubleIfEnabled(b, P999, snapshot::get999thPercentile);
+        loggerProxy.log(marker, b.toString());
+    }
+
+    private void logCounter(StringBuilder b, String name, Counter counter) {
+        b.setLength(0);
+        b.append("type=COUNTER");
+        append(b, "name", prefix(name));
+        append(b, COUNT.getCode(), counter.getCount());
+        loggerProxy.log(marker, b.toString());
+    }
+
+    private void logGauge(StringBuilder b, String name, Gauge<?> gauge) {
+        b.setLength(0);
+        b.append("type=GAUGE");
+        append(b, "name", prefix(name));
+        append(b, "value", gauge.getValue());
+        loggerProxy.log(marker, b.toString());
+    }
+
+    private void appendLongDurationIfEnabled(StringBuilder b, MetricAttribute metricAttribute,
+                                             Supplier<Long> durationSupplier) {
+        if (!getDisabledMetricAttributes().contains(metricAttribute)) {
+            append(b, metricAttribute.getCode(), convertDuration(durationSupplier.get()));
+        }
+    }
+
+    private void appendDoubleDurationIfEnabled(StringBuilder b, MetricAttribute metricAttribute,
+                                               Supplier<Double> durationSupplier) {
+        if (!getDisabledMetricAttributes().contains(metricAttribute)) {
+            append(b, metricAttribute.getCode(), convertDuration(durationSupplier.get()));
+        }
+    }
+
+    private void appendLongIfEnabled(StringBuilder b, MetricAttribute metricAttribute,
+                                     Supplier<Long> valueSupplier) {
+        if (!getDisabledMetricAttributes().contains(metricAttribute)) {
+            append(b, metricAttribute.getCode(), valueSupplier.get());
+        }
+    }
+
+    private void appendDoubleIfEnabled(StringBuilder b, MetricAttribute metricAttribute,
+                                       Supplier<Double> valueSupplier) {
+        if (!getDisabledMetricAttributes().contains(metricAttribute)) {
+            append(b, metricAttribute.getCode(), valueSupplier.get());
+        }
+    }
+
+    private void appendCountIfEnabled(StringBuilder b, Counting counting) {
+        if (!getDisabledMetricAttributes().contains(COUNT)) {
+            append(b, COUNT.getCode(), counting.getCount());
+        }
+    }
+
+    private void appendMetered(StringBuilder b, Metered meter) {
+        appendRateIfEnabled(b, M1_RATE, meter::getOneMinuteRate);
+        appendRateIfEnabled(b, M5_RATE, meter::getFiveMinuteRate);
+        appendRateIfEnabled(b, M15_RATE,  meter::getFifteenMinuteRate);
+        appendRateIfEnabled(b, MEAN_RATE,  meter::getMeanRate);
+    }
+
+    private void appendRateIfEnabled(StringBuilder b, MetricAttribute metricAttribute, Supplier<Double> rateSupplier) {
+        if (!getDisabledMetricAttributes().contains(metricAttribute)) {
+            append(b, metricAttribute.getCode(), convertRate(rateSupplier.get()));
+        }
+    }
+
+    private void append(StringBuilder b, String key, long value) {
+        b.append(", ").append(key).append('=').append(value);
+    }
+
+    private void append(StringBuilder b, String key, double value) {
+        b.append(", ").append(key).append('=').append(value);
     }
 
-    private void logCounter(String name, Counter counter) {
-        loggerProxy.log(marker, "type={}, name={}, count={}", "COUNTER", prefix(name), counter.getCount());
+    private void append(StringBuilder b, String key, String value) {
+        b.append(", ").append(key).append('=').append(value);
     }
 
-    private void logGauge(String name, Gauge gauge) {
-        loggerProxy.log(marker, "type={}, name={}, value={}", "GAUGE", prefix(name), gauge.getValue());
+    private void append(StringBuilder b, String key, Object value) {
+        b.append(", ").append(key).append('=').append(value);
     }
 
     @Override
@@ -323,7 +423,7 @@ public class Slf4jReporter extends ScheduledReporter {
             this.logger = logger;
         }
 
-        abstract void log(Marker marker, String format, Object... arguments);
+        abstract void log(Marker marker, String format);
 
         abstract boolean isEnabled(Marker marker);
     }
@@ -335,8 +435,8 @@ public class Slf4jReporter extends ScheduledReporter {
         }
 
         @Override
-        public void log(Marker marker, String format, Object... arguments) {
-            logger.debug(marker, format, arguments);
+        public void log(Marker marker, String format) {
+            logger.debug(marker, format);
         }
 
         @Override
@@ -352,8 +452,8 @@ public class Slf4jReporter extends ScheduledReporter {
         }
 
         @Override
-        public void log(Marker marker, String format, Object... arguments) {
-            logger.trace(marker, format, arguments);
+        public void log(Marker marker, String format) {
+            logger.trace(marker, format);
         }
 
         @Override
@@ -369,8 +469,8 @@ public class Slf4jReporter extends ScheduledReporter {
         }
 
         @Override
-        public void log(Marker marker, String format, Object... arguments) {
-            logger.info(marker, format, arguments);
+        public void log(Marker marker, String format) {
+            logger.info(marker, format);
         }
 
         @Override
@@ -386,8 +486,8 @@ public class Slf4jReporter extends ScheduledReporter {
         }
 
         @Override
-        public void log(Marker marker, String format, Object... arguments) {
-            logger.warn(marker, format, arguments);
+        public void log(Marker marker, String format) {
+            logger.warn(marker, format);
         }
 
         @Override
@@ -403,8 +503,8 @@ public class Slf4jReporter extends ScheduledReporter {
         }
 
         @Override
-        public void log(Marker marker, String format, Object... arguments) {
-            logger.error(marker, format, arguments);
+        public void log(Marker marker, String format) {
+            logger.error(marker, format);
         }
 
         @Override
diff --git a/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoir.java b/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoir.java
index cb47a44..9a7da21 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoir.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoir.java
@@ -76,7 +76,7 @@ public class SlidingTimeWindowArrayReservoir implements Reservoir {
     }
 
     private long getTick() {
-        for (; ; ) {
+        for ( ;; ) {
             final long oldTick = lastTick.get();
             final long tick = (clock.getTick() - startTick) * COLLISION_BUFFER;
             // ensure the tick is strictly incrementing even if there are duplicate ticks
@@ -94,7 +94,8 @@ public class SlidingTimeWindowArrayReservoir implements Reservoir {
         if (windowStart < windowEnd) {
             measurements.trim(windowStart, windowEnd);
         } else {
-            measurements.clear(windowEnd, windowStart);
+            // long overflow handling that can happen only after 1 year after class loading
+            measurements.clear();
         }
     }
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowMovingAverages.java b/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowMovingAverages.java
new file mode 100644
index 0000000..c937ef2
--- /dev/null
+++ b/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowMovingAverages.java
@@ -0,0 +1,197 @@
+package com.codahale.metrics;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.LongAdder;
+
+/**
+ * A triple of simple moving average rates (one, five and fifteen minutes rates) as needed by {@link Meter}.
+ * <p>
+ * The averages are unweighted, i.e. they include strictly only the events in the
+ * sliding time window, every event having the same weight. Unlike the
+ * the more widely used {@link ExponentialMovingAverages} implementation,
+ * with this class the moving average rate drops immediately to zero if the last
+ * marked event is older than the time window.
+ * <p>
+ * A {@link Meter} with {@link SlidingTimeWindowMovingAverages} works similarly to
+ * a {@link Histogram} with an {@link SlidingTimeWindowArrayReservoir}, but as a Meter
+ * needs to keep track only of the count of events (not the events itself), the memory
+ * overhead is much smaller. SlidingTimeWindowMovingAverages uses buckets with just one
+ * counter to accumulate the number of events (one bucket per seconds, giving 900 buckets
+ * for the 15 minutes time window).
+ */
+public class SlidingTimeWindowMovingAverages implements MovingAverages {
+
+    private static final long TIME_WINDOW_DURATION_MINUTES = 15;
+    private static final long TICK_INTERVAL = TimeUnit.SECONDS.toNanos(1);
+    private static final Duration TIME_WINDOW_DURATION = Duration.ofMinutes(TIME_WINDOW_DURATION_MINUTES);
+
+    // package private for the benefit of the unit test
+    static final int NUMBER_OF_BUCKETS = (int) (TIME_WINDOW_DURATION.toNanos() / TICK_INTERVAL);
+
+    private final AtomicLong lastTick;
+    private final Clock clock;
+
+    /**
+     * One counter per time bucket/slot (i.e. per second, see TICK_INTERVAL) for the entire
+     * time window (i.e. 15 minutes, see TIME_WINDOW_DURATION_MINUTES)
+     */
+    private ArrayList<LongAdder> buckets;
+
+    /**
+     * Index into buckets, pointing at the bucket containing the oldest counts
+     */
+    private int oldestBucketIndex;
+
+    /**
+     * Index into buckets, pointing at the bucket with the count for the current time (tick)
+     */
+    private int currentBucketIndex;
+
+    /**
+     * Instant at creation time of the time window. Used to calculate the currentBucketIndex
+     * for the instant of a given tick (instant modulo time window duration)
+     */
+    private final Instant bucketBaseTime;
+
+    /**
+     * Instant of the bucket with index oldestBucketIndex
+     */
+    Instant oldestBucketTime;
+
+    /**
+     * Creates a new {@link SlidingTimeWindowMovingAverages}.
+     */
+    public SlidingTimeWindowMovingAverages() {
+        this(Clock.defaultClock());
+    }
+
+    /**
+     * Creates a new {@link SlidingTimeWindowMovingAverages}.
+     *
+     * @param clock the clock to use for the meter ticks
+     */
+    public SlidingTimeWindowMovingAverages(Clock clock) {
+        this.clock = clock;
+        final long startTime = clock.getTick();
+        lastTick = new AtomicLong(startTime);
+
+        buckets = new ArrayList<>(NUMBER_OF_BUCKETS);
+        for (int i = 0; i < NUMBER_OF_BUCKETS; i++) {
+            buckets.add(new LongAdder());
+        }
+        bucketBaseTime = Instant.ofEpochSecond(0L, startTime);
+        oldestBucketTime = bucketBaseTime;
+        oldestBucketIndex = 0;
+        currentBucketIndex = 0;
+    }
+
+    @Override
+    public void update(long n) {
+        buckets.get(currentBucketIndex).add(n);
+    }
+
+    @Override
+    public void tickIfNecessary() {
+        final long oldTick = lastTick.get();
+        final long newTick = clock.getTick();
+        final long age = newTick - oldTick;
+        if (age >= TICK_INTERVAL) {
+            // - the newTick doesn't fall into the same slot as the oldTick anymore
+            // - newLastTick is the lower border time of the new currentBucketIndex slot
+            final long newLastTick = newTick - age % TICK_INTERVAL;
+            if (lastTick.compareAndSet(oldTick, newLastTick)) {
+                Instant currentInstant = Instant.ofEpochSecond(0L, newLastTick);
+                currentBucketIndex = normalizeIndex(calculateIndexOfTick(currentInstant));
+                cleanOldBuckets(currentInstant);
+            }
+        }
+    }
+
+    @Override
+    public double getM15Rate() {
+        return getMinuteRate(15);
+    }
+
+    @Override
+    public double getM5Rate() {
+        return getMinuteRate(5);
+    }
+
+    @Override
+    public double getM1Rate() {
+        return getMinuteRate(1);
+    }
+
+    private double getMinuteRate(int minutes) {
+        Instant now = Instant.ofEpochSecond(0L, lastTick.get());
+        return sumBuckets(now, (int) (TimeUnit.MINUTES.toNanos(minutes) / TICK_INTERVAL));
+    }
+
+    int calculateIndexOfTick(Instant tickTime) {
+        return (int) (Duration.between(bucketBaseTime, tickTime).toNanos() / TICK_INTERVAL);
+    }
+
+    int normalizeIndex(int index) {
+        int mod = index % NUMBER_OF_BUCKETS;
+        return mod >= 0 ? mod : mod + NUMBER_OF_BUCKETS;
+    }
+
+    private void cleanOldBuckets(Instant currentTick) {
+        int newOldestIndex;
+        Instant oldestStillNeededTime = currentTick.minus(TIME_WINDOW_DURATION).plusNanos(TICK_INTERVAL);
+        Instant youngestNotInWindow = oldestBucketTime.plus(TIME_WINDOW_DURATION);
+        if (oldestStillNeededTime.isAfter(youngestNotInWindow)) {
+            // there was no update() call for more than two whole TIME_WINDOW_DURATION
+            newOldestIndex = oldestBucketIndex;
+            oldestBucketTime = currentTick;
+        } else if (oldestStillNeededTime.isAfter(oldestBucketTime)) {
+            newOldestIndex = normalizeIndex(calculateIndexOfTick(oldestStillNeededTime));
+            oldestBucketTime = oldestStillNeededTime;
+        } else {
+            return;
+        }
+
+        cleanBucketRange(oldestBucketIndex, newOldestIndex);
+        oldestBucketIndex = newOldestIndex;
+    }
+
+    private void cleanBucketRange(int fromIndex, int toIndex) {
+        if (fromIndex < toIndex) {
+            for (int i = fromIndex; i < toIndex; i++) {
+                buckets.get(i).reset();
+            }
+        } else {
+            for (int i = fromIndex; i < NUMBER_OF_BUCKETS; i++) {
+                buckets.get(i).reset();
+            }
+            for (int i = 0; i < toIndex; i++) {
+                buckets.get(i).reset();
+            }
+        }
+    }
+
+    private long sumBuckets(Instant toTime, int numberOfBuckets) {
+
+        // increment toIndex to include the current bucket into the sum
+        int toIndex = normalizeIndex(calculateIndexOfTick(toTime) + 1);
+        int fromIndex = normalizeIndex(toIndex - numberOfBuckets);
+        LongAdder adder = new LongAdder();
+
+        if (fromIndex < toIndex) {
+            buckets.stream()
+                    .skip(fromIndex)
+                    .limit(toIndex - fromIndex)
+                    .mapToLong(LongAdder::longValue)
+                    .forEach(adder::add);
+        } else {
+            buckets.stream().limit(toIndex).mapToLong(LongAdder::longValue).forEach(adder::add);
+            buckets.stream().skip(fromIndex).mapToLong(LongAdder::longValue).forEach(adder::add);
+        }
+        long retval = adder.longValue();
+        return retval;
+    }
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowReservoir.java b/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowReservoir.java
index e1a9d09..7cbb90a 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowReservoir.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowReservoir.java
@@ -21,6 +21,7 @@ public class SlidingTimeWindowReservoir implements Reservoir {
     private final long window;
     private final AtomicLong lastTick;
     private final AtomicLong count;
+    private final long startTick;
 
     /**
      * Creates a new {@link SlidingTimeWindowReservoir} with the given window of time.
@@ -40,10 +41,11 @@ public class SlidingTimeWindowReservoir implements Reservoir {
      * @param clock      the {@link Clock} to use
      */
     public SlidingTimeWindowReservoir(long window, TimeUnit windowUnit, Clock clock) {
+        this.startTick = clock.getTick();
         this.clock = clock;
-        this.measurements = new ConcurrentSkipListMap<Long, Long>();
+        this.measurements = new ConcurrentSkipListMap<>();
         this.window = windowUnit.toNanos(window) * COLLISION_BUFFER;
-        this.lastTick = new AtomicLong(clock.getTick() * COLLISION_BUFFER);
+        this.lastTick = new AtomicLong((clock.getTick() - startTick) * COLLISION_BUFFER);
         this.count = new AtomicLong();
     }
 
@@ -68,9 +70,9 @@ public class SlidingTimeWindowReservoir implements Reservoir {
     }
 
     private long getTick() {
-        for (; ; ) {
+        for ( ;; ) {
             final long oldTick = lastTick.get();
-            final long tick = clock.getTick() * COLLISION_BUFFER;
+            final long tick = (clock.getTick() - startTick) * COLLISION_BUFFER;
             // ensure the tick is strictly incrementing even if there are duplicate ticks
             final long newTick = tick - oldTick > 0 ? tick : oldTick + 1;
             if (lastTick.compareAndSet(oldTick, newTick)) {
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Snapshot.java b/metrics-core/src/main/java/com/codahale/metrics/Snapshot.java
index a04804b..aca448d 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Snapshot.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Snapshot.java
@@ -10,7 +10,7 @@ public abstract class Snapshot {
     /**
      * Returns the value at the given quantile.
      *
-     * @param quantile    a given quantile, in {@code [0..1]}
+     * @param quantile a given quantile, in {@code [0..1]}
      * @return the value in the distribution at {@code quantile}
      */
     public abstract double getValue(double quantile);
@@ -28,7 +28,7 @@ public abstract class Snapshot {
      * @return the number of values
      */
     public abstract int size();
-    
+
     /**
      * Returns the median value in the distribution.
      *
@@ -117,5 +117,5 @@ public abstract class Snapshot {
      * @param output an output stream
      */
     public abstract void dump(OutputStream output);
-   
+
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Striped64.java b/metrics-core/src/main/java/com/codahale/metrics/Striped64.java
deleted file mode 100644
index 3652d95..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/Striped64.java
+++ /dev/null
@@ -1,297 +0,0 @@
-/*
- * Written by Doug Lea with assistance from members of JCP JSR-166
- * Expert Group and released to the public domain, as explained at
- * http://creativecommons.org/publicdomain/zero/1.0/
- *
- * http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/src/jsr166e/Striped64.java?revision=1.8&view=markup
- */
-
-package com.codahale.metrics;
-
-import java.util.Random;
-import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
-import java.util.concurrent.atomic.AtomicLongFieldUpdater;
-
-// CHECKSTYLE:OFF
-/**
- * A package-local class holding common representation and mechanics for classes supporting dynamic
- * striping on 64bit values. The class extends Number so that concrete subclasses must publicly do
- * so.
- */
-@SuppressWarnings("all")
-abstract class Striped64 extends Number {
-    /*
-     * This class maintains a lazily-initialized table of atomically
-     * updated variables, plus an extra "base" field. The table size
-     * is a power of two. Indexing uses masked per-thread hash codes.
-     * Nearly all declarations in this class are package-private,
-     * accessed directly by subclasses.
-     *
-     * Table entries are of class Cell; a variant of AtomicLong padded
-     * to reduce cache contention on most processors. Padding is
-     * overkill for most Atomics because they are usually irregularly
-     * scattered in memory and thus don't interfere much with each
-     * other. But Atomic objects residing in arrays will tend to be
-     * placed adjacent to each other, and so will most often share
-     * cache lines (with a huge negative performance impact) without
-     * this precaution.
-     *
-     * In part because Cells are relatively large, we avoid creating
-     * them until they are needed.  When there is no contention, all
-     * updates are made to the base field.  Upon first contention (a
-     * failed CAS on base update), the table is initialized to size 2.
-     * The table size is doubled upon further contention until
-     * reaching the nearest power of two greater than or equal to the
-     * number of CPUS. Table slots remain empty (null) until they are
-     * needed.
-     *
-     * A single spinlock ("busy") is used for initializing and
-     * resizing the table, as well as populating slots with new Cells.
-     * There is no need for a blocking lock; when the lock is not
-     * available, threads try other slots (or the base).  During these
-     * retries, there is increased contention and reduced locality,
-     * which is still better than alternatives.
-     *
-     * Per-thread hash codes are initialized to random values.
-     * Contention and/or table collisions are indicated by failed
-     * CASes when performing an update operation (see method
-     * retryUpdate). Upon a collision, if the table size is less than
-     * the capacity, it is doubled in size unless some other thread
-     * holds the lock. If a hashed slot is empty, and lock is
-     * available, a new Cell is created. Otherwise, if the slot
-     * exists, a CAS is tried.  Retries proceed by "double hashing",
-     * using a secondary hash (Marsaglia XorShift) to try to find a
-     * free slot.
-     *
-     * The table size is capped because, when there are more threads
-     * than CPUs, supposing that each thread were bound to a CPU,
-     * there would exist a perfect hash function mapping threads to
-     * slots that eliminates collisions. When we reach capacity, we
-     * search for this mapping by randomly varying the hash codes of
-     * colliding threads.  Because search is random, and collisions
-     * only become known via CAS failures, convergence can be slow,
-     * and because threads are typically not bound to CPUS forever,
-     * may not occur at all. However, despite these limitations,
-     * observed contention rates are typically low in these cases.
-     *
-     * It is possible for a Cell to become unused when threads that
-     * once hashed to it terminate, as well as in the case where
-     * doubling the table causes no thread to hash to it under
-     * expanded mask.  We do not try to detect or remove such cells,
-     * under the assumption that for long-running instances, observed
-     * contention levels will recur, so the cells will eventually be
-     * needed again; and for short-lived ones, it does not matter.
-     */
-
-    /**
-     * Padded variant of AtomicLong supporting only raw accesses plus CAS. The value field is placed
-     * between pads, hoping that the JVM doesn't reorder them.
-     * <p/>
-     * JVM intrinsics note: It would be possible to use a release-only form of CAS here, if it were
-     * provided.
-     */
-    static final class Cell {
-        volatile long p0, p1, p2, p3, p4, p5, p6;
-        volatile long value;
-        volatile long q0, q1, q2, q3, q4, q5, q6;
-
-        Cell(long x) {
-            value = x;
-        }
-
-        final boolean cas(long cmp, long val) {
-            return valueUpdater.compareAndSet(this, cmp, val);
-        }
-
-        private static final AtomicLongFieldUpdater<Cell> valueUpdater = AtomicLongFieldUpdater.newUpdater(Cell.class, "value");
-
-    }
-
-    /**
-     * Holder for the thread-local hash code. The code is initially random, but may be set to a
-     * different value upon collisions.
-     */
-    static final class HashCode {
-        static final Random rng = new Random();
-        int code;
-
-        HashCode() {
-            int h = rng.nextInt(); // Avoid zero to allow xorShift rehash
-            code = (h == 0) ? 1 : h;
-        }
-    }
-
-    /**
-     * The corresponding ThreadLocal class
-     */
-    static final class ThreadHashCode extends ThreadLocal<HashCode> {
-        public HashCode initialValue() {
-            return new HashCode();
-        }
-    }
-
-    static final AtomicLongFieldUpdater<Striped64> baseUpdater = AtomicLongFieldUpdater.newUpdater(Striped64.class, "base");
-    static final AtomicIntegerFieldUpdater<Striped64> busyUpdater = AtomicIntegerFieldUpdater.newUpdater(Striped64.class, "busy");
-
-    /**
-     * Static per-thread hash codes. Shared across all instances to reduce ThreadLocal pollution and
-     * because adjustments due to collisions in one table are likely to be appropriate for others.
-     */
-    static final ThreadHashCode threadHashCode = new ThreadHashCode();
-
-    /**
-     * Number of CPUS, to place bound on table size
-     */
-    static final int NCPU = Runtime.getRuntime().availableProcessors();
-
-    /**
-     * Table of cells. When non-null, size is a power of 2.
-     */
-    transient volatile Cell[] cells;
-
-    /**
-     * Base value, used mainly when there is no contention, but also as a fallback during table
-     * initialization races. Updated via CAS.
-     */
-    transient volatile long base;
-
-    /**
-     * Spinlock (locked via CAS) used when resizing and/or creating Cells.
-     */
-    transient volatile int busy;
-
-    /**
-     * Package-private default constructor
-     */
-    Striped64() {
-    }
-
-    /**
-     * CASes the base field.
-     */
-    final boolean casBase(long cmp, long val) {
-        return baseUpdater.compareAndSet(this, cmp, val);
-    }
-
-    /**
-     * CASes the busy field from 0 to 1 to acquire lock.
-     */
-    final boolean casBusy() {
-        return busyUpdater.compareAndSet(this, 0, 1);
-    }
-
-    /**
-     * Computes the function of current and new value. Subclasses should open-code this update
-     * function for most uses, but the virtualized form is needed within retryUpdate.
-     *
-     * @param currentValue the current value (of either base or a cell)
-     * @param newValue     the argument from a user update call
-     * @return result of the update function
-     */
-    abstract long fn(long currentValue, long newValue);
-
-    /**
-     * Handles cases of updates involving initialization, resizing, creating new Cells, and/or
-     * contention. See above for explanation. This method suffers the usual non-modularity problems
-     * of optimistic retry code, relying on rechecked sets of reads.
-     *
-     * @param x              the value
-     * @param hc             the hash code holder
-     * @param wasUncontended false if CAS failed before call
-     */
-    final void retryUpdate(long x, HashCode hc, boolean wasUncontended) {
-        int h = hc.code;
-        boolean collide = false;                // True if last slot nonempty
-        for (; ; ) {
-            Cell[] as;
-            Cell a;
-            int n;
-            long v;
-            if ((as = cells) != null && (n = as.length) > 0) {
-                if ((a = as[(n - 1) & h]) == null) {
-                    if (busy == 0) {            // Try to attach new Cell
-                        Cell r = new Cell(x);   // Optimistically create
-                        if (busy == 0 && casBusy()) {
-                            boolean created = false;
-                            try {               // Recheck under lock
-                                Cell[] rs;
-                                int m, j;
-                                if ((rs = cells) != null &&
-                                        (m = rs.length) > 0 &&
-                                        rs[j = (m - 1) & h] == null) {
-                                    rs[j] = r;
-                                    created = true;
-                                }
-                            } finally {
-                                busy = 0;
-                            }
-                            if (created)
-                                break;
-                            continue;           // Slot is now non-empty
-                        }
-                    }
-                    collide = false;
-                } else if (!wasUncontended)       // CAS already known to fail
-                    wasUncontended = true;      // Continue after rehash
-                else if (a.cas(v = a.value, fn(v, x)))
-                    break;
-                else if (n >= NCPU || cells != as)
-                    collide = false;            // At max size or stale
-                else if (!collide)
-                    collide = true;
-                else if (busy == 0 && casBusy()) {
-                    try {
-                        if (cells == as) {      // Expand table unless stale
-                            Cell[] rs = new Cell[n << 1];
-                            for (int i = 0; i < n; ++i)
-                                rs[i] = as[i];
-                            cells = rs;
-                        }
-                    } finally {
-                        busy = 0;
-                    }
-                    collide = false;
-                    continue;                   // Retry with expanded table
-                }
-                h ^= h << 13;                   // Rehash
-                h ^= h >>> 17;
-                h ^= h << 5;
-            } else if (busy == 0 && cells == as && casBusy()) {
-                boolean init = false;
-                try {                           // Initialize table
-                    if (cells == as) {
-                        Cell[] rs = new Cell[2];
-                        rs[h & 1] = new Cell(x);
-                        cells = rs;
-                        init = true;
-                    }
-                } finally {
-                    busy = 0;
-                }
-                if (init)
-                    break;
-            } else if (casBase(v = base, fn(v, x)))
-                break;                          // Fall back on using base
-        }
-        hc.code = h;                            // Record index for next time
-    }
-
-
-    /**
-     * Sets base and all cells to the given value.
-     */
-    final void internalReset(long initialValue) {
-        Cell[] as = cells;
-        base = initialValue;
-        if (as != null) {
-            int n = as.length;
-            for (int i = 0; i < n; ++i) {
-                Cell a = as[i];
-                if (a != null)
-                    a.value = initialValue;
-            }
-        }
-    }
-
-}
-// CHECKSTYLE:ON
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ThreadLocalRandom.java b/metrics-core/src/main/java/com/codahale/metrics/ThreadLocalRandom.java
deleted file mode 100644
index 14dd264..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/ThreadLocalRandom.java
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- * Written by Doug Lea with assistance from members of JCP JSR-166
- * Expert Group and released to the public domain, as explained at
- * http://creativecommons.org/publicdomain/zero/1.0/
- *
- * http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/src/main/java/util/concurrent/ThreadLocalRandom.java?view=markup
- */
-
-package com.codahale.metrics;
-
-import java.util.Random;
-
-// CHECKSTYLE:OFF
-/**
- * Copied directly from the JSR-166 project.
- */
-@SuppressWarnings("all")
-class ThreadLocalRandom extends Random {
-    // same constants as Random, but must be redeclared because private
-    private static final long multiplier = 0x5DEECE66DL;
-    private static final long addend = 0xBL;
-    private static final long mask = (1L << 48) - 1;
-
-    /**
-     * The random seed. We can't use super.seed.
-     */
-    private long rnd;
-
-    /**
-     * Initialization flag to permit calls to setSeed to succeed only while executing the Random
-     * constructor.  We can't allow others since it would cause setting seed in one part of a
-     * program to unintentionally impact other usages by the thread.
-     */
-    boolean initialized;
-
-    // Padding to help avoid memory contention among seed updates in
-    // different TLRs in the common case that they are located near
-    // each other.
-    private long pad0, pad1, pad2, pad3, pad4, pad5, pad6, pad7;
-
-    /**
-     * The actual ThreadLocal
-     */
-    private static final ThreadLocal<ThreadLocalRandom> localRandom =
-            new ThreadLocal<ThreadLocalRandom>() {
-                protected ThreadLocalRandom initialValue() {
-                    return new ThreadLocalRandom();
-                }
-            };
-
-
-    /**
-     * Constructor called only by localRandom.initialValue.
-     */
-    ThreadLocalRandom() {
-        super();
-        initialized = true;
-    }
-
-    /**
-     * Returns the current thread's {@code ThreadLocalRandom}.
-     *
-     * @return the current thread's {@code ThreadLocalRandom}
-     */
-    public static ThreadLocalRandom current() {
-        return localRandom.get();
-    }
-
-    /**
-     * Throws {@code UnsupportedOperationException}.  Setting seeds in this generator is not
-     * supported.
-     *
-     * @throws UnsupportedOperationException always
-     */
-    public void setSeed(long seed) {
-        if (initialized)
-            throw new UnsupportedOperationException();
-        rnd = (seed ^ multiplier) & mask;
-    }
-
-    protected int next(int bits) {
-        rnd = (rnd * multiplier + addend) & mask;
-        return (int) (rnd >>> (48 - bits));
-    }
-
-    /**
-     * Returns a pseudorandom, uniformly distributed value between the given least value (inclusive)
-     * and bound (exclusive).
-     *
-     * @param least the least value returned
-     * @param bound the upper bound (exclusive)
-     * @return the next value
-     * @throws IllegalArgumentException if least greater than or equal to bound
-     */
-    public int nextInt(int least, int bound) {
-        if (least >= bound)
-            throw new IllegalArgumentException();
-        return nextInt(bound - least) + least;
-    }
-
-    /**
-     * Returns a pseudorandom, uniformly distributed value between 0 (inclusive) and the specified
-     * value (exclusive).
-     *
-     * @param n the bound on the random number to be returned.  Must be positive.
-     * @return the next value
-     * @throws IllegalArgumentException if n is not positive
-     */
-    public long nextLong(long n) {
-        if (n <= 0)
-            throw new IllegalArgumentException("n must be positive");
-        // Divide n by two until small enough for nextInt. On each
-        // iteration (at most 31 of them but usually much less),
-        // randomly choose both whether to include high bit in result
-        // (offset) and whether to continue with the lower vs upper
-        // half (which makes a difference only if odd).
-        long offset = 0;
-        while (n >= Integer.MAX_VALUE) {
-            final int bits = next(2);
-            final long half = n >>> 1;
-            final long nextn = ((bits & 2) == 0) ? half : n - half;
-            if ((bits & 1) == 0)
-                offset += n - nextn;
-            n = nextn;
-        }
-        return offset + nextInt((int) n);
-    }
-
-    /**
-     * Returns a pseudorandom, uniformly distributed value between the given least value (inclusive)
-     * and bound (exclusive).
-     *
-     * @param least the least value returned
-     * @param bound the upper bound (exclusive)
-     * @return the next value
-     * @throws IllegalArgumentException if least greater than or equal to bound
-     */
-    public long nextLong(long least, long bound) {
-        if (least >= bound)
-            throw new IllegalArgumentException();
-        return nextLong(bound - least) + least;
-    }
-
-    /**
-     * Returns a pseudorandom, uniformly distributed {@code double} value between 0 (inclusive) and
-     * the specified value (exclusive).
-     *
-     * @param n the bound on the random number to be returned.  Must be positive.
-     * @return the next value
-     * @throws IllegalArgumentException if n is not positive
-     */
-    public double nextDouble(double n) {
-        if (n <= 0)
-            throw new IllegalArgumentException("n must be positive");
-        return nextDouble() * n;
-    }
-
-    /**
-     * Returns a pseudorandom, uniformly distributed value between the given least value (inclusive)
-     * and bound (exclusive).
-     *
-     * @param least the least value returned
-     * @param bound the upper bound (exclusive)
-     * @return the next value
-     * @throws IllegalArgumentException if least greater than or equal to bound
-     */
-    public double nextDouble(double least, double bound) {
-        if (least >= bound)
-            throw new IllegalArgumentException();
-        return nextDouble() * (bound - least) + least;
-    }
-
-    private static final long serialVersionUID = -5851777807851030925L;
-}
-// CHECKSTYLE:ON
diff --git a/metrics-core/src/main/java/com/codahale/metrics/ThreadLocalRandomProxy.java b/metrics-core/src/main/java/com/codahale/metrics/ThreadLocalRandomProxy.java
deleted file mode 100644
index 08dc9f0..0000000
--- a/metrics-core/src/main/java/com/codahale/metrics/ThreadLocalRandomProxy.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.codahale.metrics;
-
-import java.util.Random;
-
-/**
- * Proxy for creating thread local {@link Random} instances depending on the runtime.
- * By default it tries to use the JDK's implementation and fallbacks to the internal
- * one if the JDK doesn't provide any.
- */
-class ThreadLocalRandomProxy {
-
-    private interface Provider {
-        Random current();
-    }
-
-    /**
-     * To avoid NoClassDefFoundError during loading {@link ThreadLocalRandomProxy}
-     */
-    private static class JdkProvider implements Provider {
-
-        @Override
-        public Random current() {
-            return java.util.concurrent.ThreadLocalRandom.current();
-        }
-    }
-
-    private static class InternalProvider implements Provider {
-
-        @Override
-        public Random current() {
-            return ThreadLocalRandom.current();
-        }
-    }
-
-    private static final Provider INSTANCE = getThreadLocalProvider();
-    private static Provider getThreadLocalProvider() {
-        try {
-            final JdkProvider jdkProvider = new JdkProvider();
-            jdkProvider.current(); //  To make sure that ThreadLocalRandom actually exists in the JDK
-            return jdkProvider;
-        } catch (Throwable e) {
-            return new InternalProvider();
-        }
-    }
-
-    public static Random current() {
-        return INSTANCE.current();
-    }
-
-}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/Timer.java b/metrics-core/src/main/java/com/codahale/metrics/Timer.java
index e49841a..8070123 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/Timer.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/Timer.java
@@ -1,8 +1,9 @@
 package com.codahale.metrics;
 
-import java.io.Closeable;
+import java.time.Duration;
 import java.util.concurrent.Callable;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
 
 /**
  * A timer metric which aggregates timing durations and provides duration statistics, plus
@@ -14,12 +15,12 @@ public class Timer implements Metered, Sampling {
      *
      * @see Timer#time()
      */
-    public static class Context implements Closeable {
+    public static class Context implements AutoCloseable {
         private final Timer timer;
         private final Clock clock;
         private final long startTime;
 
-        private Context(Timer timer, Clock clock) {
+        Context(Timer timer, Clock clock) {
             this.timer = timer;
             this.clock = clock;
             this.startTime = clock.getTick();
@@ -28,6 +29,7 @@ public class Timer implements Metered, Sampling {
         /**
          * Updates the timer with the difference between current and start time. Call to this method will
          * not reset the start time. Multiple calls result in multiple updates.
+         *
          * @return the elapsed time in nanoseconds
          */
         public long stop() {
@@ -36,7 +38,9 @@ public class Timer implements Metered, Sampling {
             return elapsed;
         }
 
-        /** Equivalent to calling {@link #stop()}. */
+        /**
+         * Equivalent to calling {@link #stop()}.
+         */
         @Override
         public void close() {
             stop();
@@ -68,12 +72,16 @@ public class Timer implements Metered, Sampling {
      * Creates a new {@link Timer} that uses the given {@link Reservoir} and {@link Clock}.
      *
      * @param reservoir the {@link Reservoir} implementation the timer should use
-     * @param clock  the {@link Clock} implementation the timer should use
+     * @param clock     the {@link Clock} implementation the timer should use
      */
     public Timer(Reservoir reservoir, Clock clock) {
-        this.meter = new Meter(clock);
+        this(new Meter(clock), new Histogram(reservoir), clock);
+    }
+
+    public Timer(Meter meter, Histogram histogram, Clock clock) {
+        this.meter = meter;
+        this.histogram = histogram;
         this.clock = clock;
-        this.histogram = new Histogram(reservoir);
     }
 
     /**
@@ -86,6 +94,15 @@ public class Timer implements Metered, Sampling {
         update(unit.toNanos(duration));
     }
 
+    /**
+     * Adds a recorded duration.
+     *
+     * @param duration the {@link Duration} to add to the timer. Negative or zero value are ignored.
+     */
+    public void update(Duration duration) {
+        update(duration.toNanos());
+    }
+
     /**
      * Times and records the duration of event.
      *
@@ -104,6 +121,24 @@ public class Timer implements Metered, Sampling {
         }
     }
 
+    /**
+     * Times and records the duration of event. Should not throw exceptions, for that use the
+     * {@link #time(Callable)} method.
+     *
+     * @param event a {@link Supplier} whose {@link Supplier#get()} method implements a process
+     *              whose duration should be timed
+     * @param <T>   the type of the value returned by {@code event}
+     * @return the value returned by {@code event}
+     */
+    public <T> T timeSupplier(Supplier<T> event) {
+        final long startTime = clock.getTick();
+        try {
+            return event.get();
+        } finally {
+            update(clock.getTick() - startTime);
+        }
+    }
+
     /**
      * Times and records the duration of event.
      *
diff --git a/metrics-core/src/main/java/com/codahale/metrics/UniformReservoir.java b/metrics-core/src/main/java/com/codahale/metrics/UniformReservoir.java
index 8b461fa..a2c2983 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/UniformReservoir.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/UniformReservoir.java
@@ -1,7 +1,6 @@
 package com.codahale.metrics;
 
-import java.util.ArrayList;
-import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicLongArray;
 
@@ -13,7 +12,6 @@ import java.util.concurrent.atomic.AtomicLongArray;
  */
 public class UniformReservoir implements Reservoir {
     private static final int DEFAULT_SIZE = 1028;
-    private static final int BITS_PER_LONG = 63;
     private final AtomicLong count = new AtomicLong();
     private final AtomicLongArray values;
 
@@ -53,35 +51,19 @@ public class UniformReservoir implements Reservoir {
         if (c <= values.length()) {
             values.set((int) c - 1, value);
         } else {
-            final long r = nextLong(c);
+            final long r = ThreadLocalRandom.current().nextLong(c);
             if (r < values.length()) {
                 values.set((int) r, value);
             }
         }
     }
 
-    /**
-     * Get a pseudo-random long uniformly between 0 and n-1. Stolen from
-     * {@link java.util.Random#nextInt()}.
-     *
-     * @param n the bound
-     * @return a value select randomly from the range {@code [0..n)}.
-     */
-    private static long nextLong(long n) {
-        long bits, val;
-        do {
-            bits = ThreadLocalRandomProxy.current().nextLong() & (~(1L << BITS_PER_LONG));
-            val = bits % n;
-        } while (bits - val + (n - 1) < 0L);
-        return val;
-    }
-
     @Override
     public Snapshot getSnapshot() {
         final int s = size();
         long[] copy = new long[s];
         for (int i = 0; i < s; i++) {
-            copy[i] = values.get(i);  
+            copy[i] = values.get(i);
         }
         return new UniformSnapshot(copy);
     }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/UniformSnapshot.java b/metrics-core/src/main/java/com/codahale/metrics/UniformSnapshot.java
index 16c5e1e..de32b60 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/UniformSnapshot.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/UniformSnapshot.java
@@ -3,24 +3,23 @@ package com.codahale.metrics;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
-import java.nio.charset.Charset;
 import java.util.Arrays;
 import java.util.Collection;
 
 import static java.lang.Math.floor;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 /**
  * A statistical snapshot of a {@link UniformSnapshot}.
  */
 public class UniformSnapshot extends Snapshot {
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
 
     private final long[] values;
 
     /**
      * Create a new {@link Snapshot} with the given values.
      *
-     * @param values    an unordered set of values in the reservoir
+     * @param values an unordered set of values in the reservoir
      */
     public UniformSnapshot(Collection<Long> values) {
         final Object[] copy = values.toArray();
@@ -34,7 +33,7 @@ public class UniformSnapshot extends Snapshot {
     /**
      * Create a new {@link Snapshot} with the given values.
      *
-     * @param values    an unordered set of values in the reservoir that can be used by this class directly
+     * @param values an unordered set of values in the reservoir that can be used by this class directly
      */
     public UniformSnapshot(long[] values) {
         this.values = Arrays.copyOf(values, values.length);
@@ -44,12 +43,12 @@ public class UniformSnapshot extends Snapshot {
     /**
      * Returns the value at the given quantile.
      *
-     * @param quantile    a given quantile, in {@code [0..1]}
+     * @param quantile a given quantile, in {@code [0..1]}
      * @return the value in the distribution at {@code quantile}
      */
     @Override
     public double getValue(double quantile) {
-        if (quantile < 0.0 || quantile > 1.0 || Double.isNaN( quantile )) {
+        if (quantile < 0.0 || quantile > 1.0 || Double.isNaN(quantile)) {
             throw new IllegalArgumentException(quantile + " is not in [0..1]");
         }
 
@@ -169,13 +168,10 @@ public class UniformSnapshot extends Snapshot {
      */
     @Override
     public void dump(OutputStream output) {
-        final PrintWriter out = new PrintWriter(new OutputStreamWriter(output, UTF_8));
-        try {
+        try (PrintWriter out = new PrintWriter(new OutputStreamWriter(output, UTF_8))) {
             for (long value : values) {
                 out.printf("%d%n", value);
             }
-        } finally {
-            out.close();
         }
     }
 }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/WeightedSnapshot.java b/metrics-core/src/main/java/com/codahale/metrics/WeightedSnapshot.java
index 96200fc..e0a0046 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/WeightedSnapshot.java
+++ b/metrics-core/src/main/java/com/codahale/metrics/WeightedSnapshot.java
@@ -3,16 +3,17 @@ package com.codahale.metrics;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
-import java.nio.charset.Charset;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Comparator;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 /**
  * A statistical snapshot of a {@link WeightedSnapshot}.
  */
 public class WeightedSnapshot extends Snapshot {
-    
+
     /**
      * A single sample item with value and its weights for {@link WeightedSnapshot}.
      */
@@ -25,8 +26,6 @@ public class WeightedSnapshot extends Snapshot {
             this.weight = weight;
         }
     }
-    
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
 
     private final long[] values;
     private final double[] normWeights;
@@ -35,27 +34,17 @@ public class WeightedSnapshot extends Snapshot {
     /**
      * Create a new {@link Snapshot} with the given values.
      *
-     * @param values    an unordered set of values in the reservoir
+     * @param values an unordered set of values in the reservoir
      */
     public WeightedSnapshot(Collection<WeightedSample> values) {
-        final WeightedSample[] copy = values.toArray( new WeightedSample[]{} );
-    
-        Arrays.sort(copy, new Comparator<WeightedSample>() {
-            @Override
-            public int compare(WeightedSample o1, WeightedSample o2) {
-                if (o1.value > o2.value)
-                    return 1;
-                if (o1.value < o2.value)
-                    return -1;
-                return 0;
-            }
-        }
-        );
+        final WeightedSample[] copy = values.toArray(new WeightedSample[]{});
+
+        Arrays.sort(copy, Comparator.comparingLong(w -> w.value));
 
         this.values = new long[copy.length];
         this.normWeights = new double[copy.length];
         this.quantiles = new double[copy.length];
-        
+
         double sumWeight = 0;
         for (WeightedSample sample : copy) {
             sumWeight += sample.weight;
@@ -74,12 +63,12 @@ public class WeightedSnapshot extends Snapshot {
     /**
      * Returns the value at the given quantile.
      *
-     * @param quantile    a given quantile, in {@code [0..1]}
+     * @param quantile a given quantile, in {@code [0..1]}
      * @return the value in the distribution at {@code quantile}
      */
     @Override
     public double getValue(double quantile) {
-        if (quantile < 0.0 || quantile > 1.0 || Double.isNaN( quantile )) {
+        if (quantile < 0.0 || quantile > 1.0 || Double.isNaN(quantile)) {
             throw new IllegalArgumentException(quantile + " is not in [0..1]");
         }
 
@@ -99,7 +88,7 @@ public class WeightedSnapshot extends Snapshot {
             return values[values.length - 1];
         }
 
-        return values[(int) posx];
+        return values[posx];
     }
 
     /**
@@ -184,7 +173,7 @@ public class WeightedSnapshot extends Snapshot {
 
         for (int i = 0; i < values.length; i++) {
             final double diff = values[i] - mean;
-            variance += normWeights[i] * diff*diff;
+            variance += normWeights[i] * diff * diff;
         }
 
         return Math.sqrt(variance);
@@ -197,13 +186,10 @@ public class WeightedSnapshot extends Snapshot {
      */
     @Override
     public void dump(OutputStream output) {
-        final PrintWriter out = new PrintWriter(new OutputStreamWriter(output, UTF_8));
-        try {
+        try (PrintWriter out = new PrintWriter(new OutputStreamWriter(output, UTF_8))) {
             for (long value : values) {
                 out.printf("%d%n", value);
             }
-        } finally {
-            out.close();
         }
     }
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/CachedGaugeTest.java b/metrics-core/src/test/java/com/codahale/metrics/CachedGaugeTest.java
index 7e64cdb..38c02f2 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/CachedGaugeTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/CachedGaugeTest.java
@@ -1,13 +1,26 @@
 package com.codahale.metrics;
 
 import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertTrue;
 
 public class CachedGaugeTest {
+    private static final Logger LOGGER = LoggerFactory.getLogger(CachedGaugeTest.class);
+    private static final int THREAD_COUNT = 10;
+    private static final long RUNNING_TIME_MILLIS = TimeUnit.SECONDS.toMillis(10);
+
     private final AtomicInteger value = new AtomicInteger(0);
     private final Gauge<Integer> gauge = new CachedGauge<Integer>(100, TimeUnit.MILLISECONDS) {
         @Override
@@ -15,9 +28,21 @@ public class CachedGaugeTest {
             return value.incrementAndGet();
         }
     };
+    private final Gauge<Integer> shortTimeoutGauge = new CachedGauge<Integer>(1, TimeUnit.MILLISECONDS) {
+        @Override
+        protected Integer loadValue() {
+            try {
+                Thread.sleep(5);
+            } catch (InterruptedException e) {
+                throw new RuntimeException("Thread was interrupted", e);
+            }
+            return value.incrementAndGet();
+        }
+    };
+    private final ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
 
     @Test
-    public void cachesTheValueForTheGivenPeriod() throws Exception {
+    public void cachesTheValueForTheGivenPeriod() {
         assertThat(gauge.getValue())
                 .isEqualTo(1);
         assertThat(gauge.getValue())
@@ -37,4 +62,70 @@ public class CachedGaugeTest {
         assertThat(gauge.getValue())
                 .isEqualTo(2);
     }
+
+    @Test
+    public void reloadsCachedValueInNegativeTime() throws Exception {
+        AtomicLong time = new AtomicLong(-2L);
+        Clock clock = new Clock() {
+            @Override
+            public long getTick() {
+                return time.get();
+            }
+        };
+        Gauge<Integer> clockGauge = new CachedGauge<Integer>(clock, 1, TimeUnit.NANOSECONDS) {
+            @Override
+            protected Integer loadValue() {
+                return value.incrementAndGet();
+            }
+        };
+        assertThat(clockGauge.getValue())
+                .isEqualTo(1);
+        assertThat(clockGauge.getValue())
+                .isEqualTo(1);
+
+        time.set(-1L);
+
+        assertThat(clockGauge.getValue())
+                .isEqualTo(2);
+        assertThat(clockGauge.getValue())
+                .isEqualTo(2);
+    }
+
+    @Test
+    public void multipleThreadAccessReturnsConsistentResults() throws Exception {
+        List<Future<Boolean>> futures = new ArrayList<>(THREAD_COUNT);
+
+        for (int i = 0; i < THREAD_COUNT; i++) {
+            Future<Boolean> future = executor.submit(() -> {
+                long startTime = System.currentTimeMillis();
+                int lastValue = 0;
+
+                do {
+                    Integer newValue = shortTimeoutGauge.getValue();
+
+                    if (newValue == null) {
+                        LOGGER.warn("Cached gauge returned null value");
+                        return false;
+                    }
+
+                    if (newValue < lastValue) {
+                        LOGGER.error("Cached gauge returned stale value, last: {}, new: {}", lastValue, newValue);
+                        return false;
+                    }
+
+                    lastValue = newValue;
+                } while (System.currentTimeMillis() - startTime <= RUNNING_TIME_MILLIS);
+
+                return true;
+            });
+
+            futures.add(future);
+        }
+
+        for (int i = 0; i < futures.size(); i++) {
+            assertTrue("Future " + i + " failed", futures.get(i).get());
+        }
+
+        executor.shutdown();
+    }
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/ChunkedAssociativeLongArrayTest.java b/metrics-core/src/test/java/com/codahale/metrics/ChunkedAssociativeLongArrayTest.java
index b83a7e1..62dda3e 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/ChunkedAssociativeLongArrayTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/ChunkedAssociativeLongArrayTest.java
@@ -6,37 +6,6 @@ import org.junit.Test;
 
 public class ChunkedAssociativeLongArrayTest {
 
-    @Test
-    public void testClear() {
-        ChunkedAssociativeLongArray array = new ChunkedAssociativeLongArray(3);
-        array.put(-3, 3);
-        array.put(-2, 1);
-        array.put(0, 5);
-        array.put(3, 0);
-        array.put(9, 8);
-        array.put(15, 0);
-        array.put(19, 5);
-        array.put(21, 5);
-        array.put(34, -9);
-        array.put(109, 5);
-
-        then(array.out())
-            .isEqualTo("[(-3: 3) (-2: 1) (0: 5) ]->[(3: 0) (9: 8) (15: 0) ]->[(19: 5) (21: 5) (34: -9) ]->[(109: 5) ]");
-        then(array.values())
-            .isEqualTo(new long[]{3, 1, 5, 0, 8, 0, 5, 5, -9, 5});
-        then(array.size())
-            .isEqualTo(10);
-
-        array.clear(-2, 20);
-        then(array.out())
-            .isEqualTo("[(-3: 3) ]->[(21: 5) (34: -9) ]->[(109: 5) ]");
-        then(array.values())
-            .isEqualTo(new long[]{3, 5, -9, 5});
-        then(array.size())
-            .isEqualTo(4);
-    }
-
-
     @Test
     public void testTrim() {
         ChunkedAssociativeLongArray array = new ChunkedAssociativeLongArray(3);
@@ -52,20 +21,20 @@ public class ChunkedAssociativeLongArrayTest {
         array.put(109, 5);
 
         then(array.out())
-            .isEqualTo("[(-3: 3) (-2: 1) (0: 5) ]->[(3: 0) (9: 8) (15: 0) ]->[(19: 5) (21: 5) (34: -9) ]->[(109: 5) ]");
+                .isEqualTo("[(-3: 3) (-2: 1) (0: 5) ]->[(3: 0) (9: 8) (15: 0) ]->[(19: 5) (21: 5) (34: -9) ]->[(109: 5) ]");
         then(array.values())
-            .isEqualTo(new long[]{3, 1, 5, 0, 8, 0, 5, 5, -9, 5});
+                .isEqualTo(new long[]{3, 1, 5, 0, 8, 0, 5, 5, -9, 5});
         then(array.size())
-            .isEqualTo(10);
+                .isEqualTo(10);
 
         array.trim(-2, 20);
 
         then(array.out())
-            .isEqualTo("[(-2: 1) (0: 5) ]->[(3: 0) (9: 8) (15: 0) ]->[(19: 5) ]");
+                .isEqualTo("[(-2: 1) (0: 5) ]->[(3: 0) (9: 8) (15: 0) ]->[(19: 5) ]");
         then(array.values())
-            .isEqualTo(new long[]{1, 5, 0, 8, 0, 5});
+                .isEqualTo(new long[]{1, 5, 0, 8, 0, 5});
         then(array.size())
-            .isEqualTo(6);
+                .isEqualTo(6);
 
     }
-}
\ No newline at end of file
+}
diff --git a/metrics-core/src/test/java/com/codahale/metrics/ClassMetadataTest.java b/metrics-core/src/test/java/com/codahale/metrics/ClassMetadataTest.java
new file mode 100644
index 0000000..ab95351
--- /dev/null
+++ b/metrics-core/src/test/java/com/codahale/metrics/ClassMetadataTest.java
@@ -0,0 +1,13 @@
+package com.codahale.metrics;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ClassMetadataTest {
+    @Test
+    public void testParameterMetadataIsAvailable() throws NoSuchMethodException {
+        assertThat(DefaultSettableGauge.class.getConstructor(Object.class).getParameters())
+                .allSatisfy(parameter -> assertThat(parameter.isNamePresent()).isTrue());
+    }
+}
\ No newline at end of file
diff --git a/metrics-core/src/test/java/com/codahale/metrics/ClockTest.java b/metrics-core/src/test/java/com/codahale/metrics/ClockTest.java
index 3b661f4..79d6b81 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/ClockTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/ClockTest.java
@@ -2,40 +2,26 @@ package com.codahale.metrics;
 
 import org.junit.Test;
 
-import java.lang.management.ManagementFactory;
-
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.offset;
 
 public class ClockTest {
-    @Test
-    public void cpuTimeClock() throws Exception {
-        final Clock.CpuTimeClock clock = new Clock.CpuTimeClock();
-
-        assertThat((double) clock.getTime())
-                .isEqualTo(System.currentTimeMillis(),
-                           offset(100.0));
-
-        assertThat((double) clock.getTick())
-                   .isEqualTo(ManagementFactory.getThreadMXBean().getCurrentThreadCpuTime(),
-                              offset(1000000.0));
-    }
 
     @Test
-    public void userTimeClock() throws Exception {
+    public void userTimeClock() {
         final Clock.UserTimeClock clock = new Clock.UserTimeClock();
 
         assertThat((double) clock.getTime())
                 .isEqualTo(System.currentTimeMillis(),
-                           offset(100.0));
+                        offset(100.0));
 
         assertThat((double) clock.getTick())
                 .isEqualTo(System.nanoTime(),
-                           offset(100000.0));
+                        offset(1000000.0));
     }
 
     @Test
-    public void defaultsToUserTime() throws Exception {
+    public void defaultsToUserTime() {
         assertThat(Clock.defaultClock())
                 .isInstanceOf(Clock.UserTimeClock.class);
     }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/ConsoleReporterTest.java b/metrics-core/src/test/java/com/codahale/metrics/ConsoleReporterTest.java
index caaa00b..43eb6ed 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/ConsoleReporterTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/ConsoleReporterTest.java
@@ -1,17 +1,19 @@
 package com.codahale.metrics;
 
+import org.apache.commons.lang3.JavaVersion;
+import org.apache.commons.lang3.SystemUtils;
 import org.junit.Before;
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
 import java.io.PrintStream;
 import java.io.UnsupportedEncodingException;
+import java.util.EnumSet;
 import java.util.Locale;
-import java.util.TimeZone;
+import java.util.Set;
 import java.util.SortedMap;
+import java.util.TimeZone;
 import java.util.TreeMap;
-import java.util.EnumSet;
-import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -20,41 +22,49 @@ import static org.mockito.Mockito.when;
 
 public class ConsoleReporterTest {
     private final Locale locale = Locale.US;
-    private final TimeZone timeZone = TimeZone.getTimeZone("PST");
+    private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
 
     private final MetricRegistry registry = mock(MetricRegistry.class);
     private final Clock clock = mock(Clock.class);
     private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
     private final PrintStream output = new PrintStream(bytes);
     private final ConsoleReporter reporter = ConsoleReporter.forRegistry(registry)
-                                                            .outputTo(output)
-                                                            .formattedFor(locale)
-                                                            .withClock(clock)
-                                                            .formattedFor(timeZone)
-                                                            .convertRatesTo(TimeUnit.SECONDS)
-                                                            .convertDurationsTo(TimeUnit.MILLISECONDS)
-                                                            .filter(MetricFilter.ALL)
-                                                            .build();
+            .outputTo(output)
+            .formattedFor(locale)
+            .withClock(clock)
+            .formattedFor(timeZone)
+            .convertRatesTo(TimeUnit.SECONDS)
+            .convertDurationsTo(TimeUnit.MILLISECONDS)
+            .filter(MetricFilter.ALL)
+            .build();
+    private String dateHeader;
 
     @Before
     public void setUp() throws Exception {
         when(clock.getTime()).thenReturn(1363568676000L);
+        // JDK9 has changed the java.text.DateFormat API implementation according to Unicode.
+        // See http://mail.openjdk.java.net/pipermail/jdk9-dev/2017-April/005732.html
+        dateHeader = SystemUtils.isJavaVersionAtMost(JavaVersion.JAVA_1_8) ?
+                "3/17/13 6:04:36 PM =============================================================" :
+                // https://bugs.openjdk.org/browse/JDK-8304925
+                SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_20) ?
+                        "3/17/13, 6:04:36\u202FPM ============================================================" :
+                        "3/17/13, 6:04:36 PM ============================================================";
     }
 
     @Test
     public void reportsGaugeValues() throws Exception {
-        final Gauge gauge = mock(Gauge.class);
-        when(gauge.getValue()).thenReturn(1);
+        final Gauge<Integer> gauge = () -> 1;
 
         reporter.report(map("gauge", gauge),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+                map(),
+                map(),
+                map(),
+                map());
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Gauges ----------------------------------------------------------------------",
                         "gauge",
@@ -69,15 +79,15 @@ public class ConsoleReporterTest {
         final Counter counter = mock(Counter.class);
         when(counter.getCount()).thenReturn(100L);
 
-        reporter.report(this.<Gauge>map(),
-                        map("test.counter", counter),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+        reporter.report(map(),
+                map("test.counter", counter),
+                map(),
+                map(),
+                map());
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Counters --------------------------------------------------------------------",
                         "test.counter",
@@ -106,15 +116,15 @@ public class ConsoleReporterTest {
 
         when(histogram.getSnapshot()).thenReturn(snapshot);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        map("test.histogram", histogram),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+        reporter.report(map(),
+                map(),
+                map("test.histogram", histogram),
+                map(),
+                map());
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Histograms ------------------------------------------------------------------",
                         "test.histogram",
@@ -143,15 +153,15 @@ public class ConsoleReporterTest {
         when(meter.getFiveMinuteRate()).thenReturn(4.0);
         when(meter.getFifteenMinuteRate()).thenReturn(5.0);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        map("test.meter", meter),
-                        this.<Timer>map());
+        reporter.report(map(),
+                map(),
+                map(),
+                map("test.meter", meter),
+                map());
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Meters ----------------------------------------------------------------------",
                         "test.meter",
@@ -185,19 +195,19 @@ public class ConsoleReporterTest {
         when(snapshot.get98thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(800));
         when(snapshot.get99thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(900));
         when(snapshot.get999thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS
-                                                                        .toNanos(1000));
+                .toNanos(1000));
 
         when(timer.getSnapshot()).thenReturn(snapshot);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        map("test.another.timer", timer));
+        reporter.report(map(),
+                map(),
+                map(),
+                map(),
+                map("test.another.timer", timer));
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Timers ----------------------------------------------------------------------",
                         "test.another.timer",
@@ -234,7 +244,7 @@ public class ConsoleReporterTest {
                 .convertDurationsTo(TimeUnit.MILLISECONDS)
                 .filter(MetricFilter.ALL)
                 .disabledMetricAttributes(disabledMetricAttributes)
-            .build();
+                .build();
 
         final Meter meter = mock(Meter.class);
         when(meter.getCount()).thenReturn(1L);
@@ -243,15 +253,15 @@ public class ConsoleReporterTest {
         when(meter.getFiveMinuteRate()).thenReturn(4.0);
         when(meter.getFifteenMinuteRate()).thenReturn(5.0);
 
-        customReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                this.<Histogram>map(),
+        customReporter.report(map(),
+                map(),
+                map(),
                 map("test.meter", meter),
-                this.<Timer>map());
+                map());
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Meters ----------------------------------------------------------------------",
                         "test.meter",
@@ -299,15 +309,15 @@ public class ConsoleReporterTest {
 
         when(timer.getSnapshot()).thenReturn(snapshot);
 
-        customReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map(),
+        customReporter.report(map(),
+                map(),
+                map(),
+                map(),
                 map("test.another.timer", timer));
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Timers ----------------------------------------------------------------------",
                         "test.another.timer",
@@ -359,15 +369,15 @@ public class ConsoleReporterTest {
 
         when(histogram.getSnapshot()).thenReturn(snapshot);
 
-        customReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
+        customReporter.report(map(),
+                map(),
                 map("test.histogram", histogram),
-                this.<Meter>map(),
-                this.<Timer>map());
+                map(),
+                map());
 
         assertThat(consoleOutput())
                 .isEqualTo(lines(
-                        "3/17/13 6:04:36 PM =============================================================",
+                        dateHeader,
                         "",
                         "-- Histograms ------------------------------------------------------------------",
                         "test.histogram",
@@ -396,11 +406,11 @@ public class ConsoleReporterTest {
     }
 
     private <T> SortedMap<String, T> map() {
-        return new TreeMap<String, T>();
+        return new TreeMap<>();
     }
 
     private <T> SortedMap<String, T> map(String name, T metric) {
-        final TreeMap<String, T> map = new TreeMap<String, T>();
+        final TreeMap<String, T> map = new TreeMap<>();
         map.put(name, metric);
         return map;
     }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/CounterTest.java b/metrics-core/src/test/java/com/codahale/metrics/CounterTest.java
index f2a3f46..79530b7 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/CounterTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/CounterTest.java
@@ -8,13 +8,13 @@ public class CounterTest {
     private final Counter counter = new Counter();
 
     @Test
-    public void startsAtZero() throws Exception {
+    public void startsAtZero() {
         assertThat(counter.getCount())
                 .isZero();
     }
 
     @Test
-    public void incrementsByOne() throws Exception {
+    public void incrementsByOne() {
         counter.inc();
 
         assertThat(counter.getCount())
@@ -22,7 +22,7 @@ public class CounterTest {
     }
 
     @Test
-    public void incrementsByAnArbitraryDelta() throws Exception {
+    public void incrementsByAnArbitraryDelta() {
         counter.inc(12);
 
         assertThat(counter.getCount())
@@ -30,7 +30,7 @@ public class CounterTest {
     }
 
     @Test
-    public void decrementsByOne() throws Exception {
+    public void decrementsByOne() {
         counter.dec();
 
         assertThat(counter.getCount())
@@ -38,7 +38,7 @@ public class CounterTest {
     }
 
     @Test
-    public void decrementsByAnArbitraryDelta() throws Exception {
+    public void decrementsByAnArbitraryDelta() {
         counter.dec(12);
 
         assertThat(counter.getCount())
@@ -46,7 +46,7 @@ public class CounterTest {
     }
 
     @Test
-    public void incrementByNegativeDelta() throws Exception {
+    public void incrementByNegativeDelta() {
         counter.inc(-12);
 
         assertThat(counter.getCount())
@@ -54,7 +54,7 @@ public class CounterTest {
     }
 
     @Test
-    public void decrementByNegativeDelta() throws Exception {
+    public void decrementByNegativeDelta() {
         counter.dec(-12);
 
         assertThat(counter.getCount())
diff --git a/metrics-core/src/test/java/com/codahale/metrics/CsvReporterTest.java b/metrics-core/src/test/java/com/codahale/metrics/CsvReporterTest.java
index ae40c2f..6ef4cdd 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/CsvReporterTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/CsvReporterTest.java
@@ -7,6 +7,7 @@ import org.junit.rules.TemporaryFolder;
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.util.Locale;
 import java.util.SortedMap;
@@ -14,10 +15,13 @@ import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 public class CsvReporterTest {
-    @Rule public final TemporaryFolder folder = new TemporaryFolder();
+    @Rule
+    public final TemporaryFolder folder = new TemporaryFolder();
 
     private final MetricRegistry registry = mock(MetricRegistry.class);
     private final Clock clock = mock(Clock.class);
@@ -32,24 +36,23 @@ public class CsvReporterTest {
         this.dataDirectory = folder.newFolder();
 
         this.reporter = CsvReporter.forRegistry(registry)
-                                   .formatFor(Locale.US)
-                                   .convertRatesTo(TimeUnit.SECONDS)
-                                   .convertDurationsTo(TimeUnit.MILLISECONDS)
-                                   .withClock(clock)
-                                   .filter(MetricFilter.ALL)
-                                   .build(dataDirectory);
+                .formatFor(Locale.US)
+                .convertRatesTo(TimeUnit.SECONDS)
+                .convertDurationsTo(TimeUnit.MILLISECONDS)
+                .withClock(clock)
+                .filter(MetricFilter.ALL)
+                .build(dataDirectory);
     }
 
     @Test
     public void reportsGaugeValues() throws Exception {
-        final Gauge gauge = mock(Gauge.class);
-        when(gauge.getValue()).thenReturn(1);
+        final Gauge<Integer> gauge = () -> 1;
 
         reporter.report(map("gauge", gauge),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+                map(),
+                map(),
+                map(),
+                map());
 
         assertThat(fileContents("gauge.csv"))
                 .isEqualTo(csv(
@@ -63,11 +66,11 @@ public class CsvReporterTest {
         final Counter counter = mock(Counter.class);
         when(counter.getCount()).thenReturn(100L);
 
-        reporter.report(this.<Gauge>map(),
-                        map("test.counter", counter),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+        reporter.report(map(),
+                map("test.counter", counter),
+                map(),
+                map(),
+                map());
 
         assertThat(fileContents("test.counter.csv"))
                 .isEqualTo(csv(
@@ -95,11 +98,11 @@ public class CsvReporterTest {
 
         when(histogram.getSnapshot()).thenReturn(snapshot);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        map("test.histogram", histogram),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+        reporter.report(map(),
+                map(),
+                map("test.histogram", histogram),
+                map(),
+                map());
 
         assertThat(fileContents("test.histogram.csv"))
                 .isEqualTo(csv(
@@ -110,18 +113,13 @@ public class CsvReporterTest {
 
     @Test
     public void reportsMeterValues() throws Exception {
-        final Meter meter = mock(Meter.class);
-        when(meter.getCount()).thenReturn(1L);
-        when(meter.getMeanRate()).thenReturn(2.0);
-        when(meter.getOneMinuteRate()).thenReturn(3.0);
-        when(meter.getFiveMinuteRate()).thenReturn(4.0);
-        when(meter.getFifteenMinuteRate()).thenReturn(5.0);
+        final Meter meter = mockMeter();
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        map("test.meter", meter),
-                        this.<Timer>map());
+        reporter.report(map(),
+                map(),
+                map(),
+                map("test.meter", meter),
+                map());
 
         assertThat(fileContents("test.meter.csv"))
                 .isEqualTo(csv(
@@ -153,11 +151,11 @@ public class CsvReporterTest {
 
         when(timer.getSnapshot()).thenReturn(snapshot);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        map("test.another.timer", timer));
+        reporter.report(map(),
+                map(),
+                map(),
+                map(),
+                map("test.another.timer", timer));
 
         assertThat(fileContents("test.another.timer.csv"))
                 .isEqualTo(csv(
@@ -175,18 +173,54 @@ public class CsvReporterTest {
                 .withCsvFileProvider(fileProvider)
                 .build(dataDirectory);
 
-        final Gauge gauge = mock(Gauge.class);
-        when(gauge.getValue()).thenReturn(1);
+        final Gauge<Integer> gauge = () -> 1;
 
         reporter.report(map("gauge", gauge),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map(),
-                this.<Timer>map());
+                map(),
+                map(),
+                map(),
+                map());
 
         verify(fileProvider).getFile(dataDirectory, "gauge");
     }
 
+    @Test
+    public void itFormatsWithCustomSeparator() throws Exception {
+        final Meter meter = mockMeter();
+
+        CsvReporter customSeparatorReporter = CsvReporter.forRegistry(registry)
+                .formatFor(Locale.US)
+                .withSeparator("|")
+                .convertRatesTo(TimeUnit.SECONDS)
+                .convertDurationsTo(TimeUnit.MILLISECONDS)
+                .withClock(clock)
+                .filter(MetricFilter.ALL)
+                .build(dataDirectory);
+
+        customSeparatorReporter.report(map(),
+                map(),
+                map(),
+                map("test.meter", meter),
+                map());
+
+        assertThat(fileContents("test.meter.csv"))
+                .isEqualTo(csv(
+                        "t|count|mean_rate|m1_rate|m5_rate|m15_rate|rate_unit",
+                        "19910191|1|2.000000|3.000000|4.000000|5.000000|events/second"
+                ));
+    }
+
+    private Meter mockMeter() {
+        final Meter meter = mock(Meter.class);
+        when(meter.getCount()).thenReturn(1L);
+        when(meter.getMeanRate()).thenReturn(2.0);
+        when(meter.getOneMinuteRate()).thenReturn(3.0);
+        when(meter.getFiveMinuteRate()).thenReturn(4.0);
+        when(meter.getFifteenMinuteRate()).thenReturn(5.0);
+
+        return meter;
+    }
+
     private String csv(String... lines) {
         final StringBuilder builder = new StringBuilder();
         for (String line : lines) {
@@ -196,15 +230,15 @@ public class CsvReporterTest {
     }
 
     private String fileContents(String filename) throws IOException {
-        return new String(Files.readAllBytes(new File(dataDirectory, filename).toPath()));
+        return new String(Files.readAllBytes(new File(dataDirectory, filename).toPath()), StandardCharsets.UTF_8);
     }
 
     private <T> SortedMap<String, T> map() {
-        return new TreeMap<String, T>();
+        return new TreeMap<>();
     }
 
     private <T> SortedMap<String, T> map(String name, T metric) {
-        final TreeMap<String, T> map = new TreeMap<String, T>();
+        final TreeMap<String, T> map = new TreeMap<>();
         map.put(name, metric);
         return map;
     }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/DefaultObjectNameFactoryTest.java b/metrics-core/src/test/java/com/codahale/metrics/DefaultObjectNameFactoryTest.java
deleted file mode 100644
index 20c7ad5..0000000
--- a/metrics-core/src/test/java/com/codahale/metrics/DefaultObjectNameFactoryTest.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.codahale.metrics;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import javax.management.ObjectName;
-
-import org.junit.Test;
-
-public class DefaultObjectNameFactoryTest {
-
-	@Test
-	public void createsObjectNameWithDomainInInput() {
-		DefaultObjectNameFactory f = new DefaultObjectNameFactory();
-		ObjectName on = f.createName("type", "com.domain", "something.with.dots");
-		assertThat(on.getDomain()).isEqualTo("com.domain");
-	}
-
-	@Test
-	public void createsObjectNameWithNameAsKeyPropertyName() {
-		DefaultObjectNameFactory f = new DefaultObjectNameFactory();
-		ObjectName on = f.createName("type", "com.domain", "something.with.dots");
-		assertThat(on.getKeyProperty("name")).isEqualTo("something.with.dots");
-	}
-}
diff --git a/metrics-core/src/test/java/com/codahale/metrics/DefaultSettableGaugeTest.java b/metrics-core/src/test/java/com/codahale/metrics/DefaultSettableGaugeTest.java
new file mode 100644
index 0000000..c6cdb3f
--- /dev/null
+++ b/metrics-core/src/test/java/com/codahale/metrics/DefaultSettableGaugeTest.java
@@ -0,0 +1,26 @@
+package com.codahale.metrics;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class DefaultSettableGaugeTest {
+    @Test
+    public void newSettableGaugeWithoutDefaultReturnsNull() {
+        DefaultSettableGauge<String> gauge = new DefaultSettableGauge<>();
+        assertThat(gauge.getValue()).isNull();
+    }
+
+    @Test
+    public void newSettableGaugeWithDefaultReturnsDefault() {
+        DefaultSettableGauge<String> gauge = new DefaultSettableGauge<>("default");
+        assertThat(gauge.getValue()).isEqualTo("default");
+    }
+
+    @Test
+    public void setValueOverwritesExistingValue() {
+        DefaultSettableGauge<String> gauge = new DefaultSettableGauge<>("default");
+        gauge.setValue("test");
+        assertThat(gauge.getValue()).isEqualTo("test");
+    }
+}
diff --git a/metrics-core/src/test/java/com/codahale/metrics/DerivativeGaugeTest.java b/metrics-core/src/test/java/com/codahale/metrics/DerivativeGaugeTest.java
index 8f089fd..1b0761e 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/DerivativeGaugeTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/DerivativeGaugeTest.java
@@ -5,12 +5,7 @@ import org.junit.Test;
 import static org.assertj.core.api.Assertions.assertThat;
 
 public class DerivativeGaugeTest {
-    private final Gauge<String> gauge1 = new Gauge<String>() {
-        @Override
-        public String getValue() {
-            return "woo";
-        }
-    };
+    private final Gauge<String> gauge1 = () -> "woo";
     private final Gauge<Integer> gauge2 = new DerivativeGauge<String, Integer>(gauge1) {
         @Override
         protected Integer transform(String value) {
@@ -19,7 +14,7 @@ public class DerivativeGaugeTest {
     };
 
     @Test
-    public void returnsATransformedValue() throws Exception {
+    public void returnsATransformedValue() {
         assertThat(gauge2.getValue())
                 .isEqualTo(3);
     }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/EWMATest.java b/metrics-core/src/test/java/com/codahale/metrics/EWMATest.java
index 9e95235..6c723d9 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/EWMATest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/EWMATest.java
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.offset;
 
 public class EWMATest {
     @Test
-    public void aOneMinuteEWMAWithAValueOfThree() throws Exception {
+    public void aOneMinuteEWMAWithAValueOfThree() {
         final EWMA ewma = EWMA.oneMinuteEWMA();
         ewma.update(3);
         ewma.tick();
@@ -78,7 +78,7 @@ public class EWMATest {
     }
 
     @Test
-    public void aFiveMinuteEWMAWithAValueOfThree() throws Exception {
+    public void aFiveMinuteEWMAWithAValueOfThree() {
         final EWMA ewma = EWMA.fiveMinuteEWMA();
         ewma.update(3);
         ewma.tick();
@@ -147,7 +147,7 @@ public class EWMATest {
     }
 
     @Test
-    public void aFifteenMinuteEWMAWithAValueOfThree() throws Exception {
+    public void aFifteenMinuteEWMAWithAValueOfThree() {
         final EWMA ewma = EWMA.fifteenMinuteEWMA();
         ewma.update(3);
         ewma.tick();
diff --git a/metrics-core/src/test/java/com/codahale/metrics/ExponentiallyDecayingReservoirTest.java b/metrics-core/src/test/java/com/codahale/metrics/ExponentiallyDecayingReservoirTest.java
index 34b7cbf..8708637 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/ExponentiallyDecayingReservoirTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/ExponentiallyDecayingReservoirTest.java
@@ -2,17 +2,63 @@ package com.codahale.metrics;
 
 import com.codahale.metrics.Timer.Context;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
+@RunWith(Parameterized.class)
 public class ExponentiallyDecayingReservoirTest {
+
+    public enum ReservoirFactory {
+        EXPONENTIALLY_DECAYING() {
+            @Override
+            Reservoir create(int size, double alpha, Clock clock) {
+                return new ExponentiallyDecayingReservoir(size, alpha, clock);
+            }
+        },
+
+        LOCK_FREE_EXPONENTIALLY_DECAYING() {
+            @Override
+            Reservoir create(int size, double alpha, Clock clock) {
+                return LockFreeExponentiallyDecayingReservoir.builder()
+                        .size(size)
+                        .alpha(alpha)
+                        .clock(clock)
+                        .build();
+            }
+        };
+
+        abstract Reservoir create(int size, double alpha, Clock clock);
+
+        Reservoir create(int size, double alpha) {
+            return create(size, alpha, Clock.defaultClock());
+        }
+    }
+
+    @Parameterized.Parameters(name = "{index}: {0}")
+    public static Collection<Object[]> reservoirs() {
+        return Arrays.stream(ReservoirFactory.values())
+                .map(value -> new Object[] {value})
+                .collect(Collectors.toList());
+    }
+
+    private final ReservoirFactory reservoirFactory;
+
+    public ExponentiallyDecayingReservoirTest(ReservoirFactory reservoirFactory) {
+        this.reservoirFactory = reservoirFactory;
+    }
+
     @Test
-    public void aReservoirOf100OutOf1000Elements() throws Exception {
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(100, 0.99);
+    public void aReservoirOf100OutOf1000Elements() {
+        final Reservoir reservoir = reservoirFactory.create(100, 0.99);
         for (int i = 0; i < 1000; i++) {
             reservoir.update(i);
         }
@@ -29,8 +75,8 @@ public class ExponentiallyDecayingReservoirTest {
     }
 
     @Test
-    public void aReservoirOf100OutOf10Elements() throws Exception {
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(100, 0.99);
+    public void aReservoirOf100OutOf10Elements() {
+        final Reservoir reservoir = reservoirFactory.create(100, 0.99);
         for (int i = 0; i < 10; i++) {
             reservoir.update(i);
         }
@@ -47,8 +93,8 @@ public class ExponentiallyDecayingReservoirTest {
     }
 
     @Test
-    public void aHeavilyBiasedReservoirOf100OutOf1000Elements() throws Exception {
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(1000, 0.01);
+    public void aHeavilyBiasedReservoirOf100OutOf1000Elements() {
+        final Reservoir reservoir = reservoirFactory.create(1000, 0.01);
         for (int i = 0; i < 100; i++) {
             reservoir.update(i);
         }
@@ -68,9 +114,7 @@ public class ExponentiallyDecayingReservoirTest {
     @Test
     public void longPeriodsOfInactivityShouldNotCorruptSamplingState() {
         final ManualClock clock = new ManualClock();
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(10,
-                                                                                            0.015,
-                                                                                            clock);
+        final Reservoir reservoir = reservoirFactory.create(10, 0.15, clock);
 
         // add 1000 values at a rate of 10 values/second
         for (int i = 0; i < 1000; i++) {
@@ -82,14 +126,13 @@ public class ExponentiallyDecayingReservoirTest {
         assertAllValuesBetween(reservoir, 1000, 2000);
 
         // wait for 15 hours and add another value.
-        // this should trigger a rescale. Note that the number of samples will be reduced to 2
-        // because of the very small scaling factor that will make all existing priorities equal to
-        // zero after rescale.
+        // this should trigger a rescale. Note that the number of samples will be reduced to 1
+        // because scaling factor equal to zero will remove all existing entries after rescale.
         clock.addHours(15);
         reservoir.update(2000);
         assertThat(reservoir.getSnapshot().size())
                 .isEqualTo(1);
-        assertAllValuesBetween(reservoir, 1000, 3000);
+        assertAllValuesBetween(reservoir, 1000, 2001);
 
 
         // add 1000 values at a rate of 10 values/second
@@ -105,7 +148,7 @@ public class ExponentiallyDecayingReservoirTest {
     @Test
     public void longPeriodsOfInactivity_fetchShouldResample() {
         final ManualClock clock = new ManualClock();
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(10,
+        final Reservoir reservoir = reservoirFactory.create(10,
                 0.015,
                 clock);
 
@@ -118,12 +161,10 @@ public class ExponentiallyDecayingReservoirTest {
                 .isEqualTo(10);
         assertAllValuesBetween(reservoir, 1000, 2000);
 
-        // wait for 15 hours and add another value.
-        // this should trigger a rescale. Note that the number of samples will be reduced to 2
-        // because of the very small scaling factor that will make all existing priorities equal to
-        // zero after rescale.
+        // wait for 20 hours and take snapshot.
+        // this should trigger a rescale. Note that the number of samples will be reduced to 0
+        // because scaling factor equal to zero will remove all existing entries after rescale.
         clock.addHours(20);
-
         Snapshot snapshot = reservoir.getSnapshot();
         assertThat(snapshot.getMax()).isEqualTo(0);
         assertThat(snapshot.getMean()).isEqualTo(0);
@@ -133,7 +174,7 @@ public class ExponentiallyDecayingReservoirTest {
 
     @Test
     public void emptyReservoirSnapshot_shouldReturnZeroForAllValues() {
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(100, 0.015,
+        final Reservoir reservoir = reservoirFactory.create(100, 0.015,
                 new ManualClock());
 
         Snapshot snapshot = reservoir.getSnapshot();
@@ -146,7 +187,7 @@ public class ExponentiallyDecayingReservoirTest {
     @Test
     public void removeZeroWeightsInSamplesToPreventNaNInMeanValues() {
         final ManualClock clock = new ManualClock();
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(1028, 0.015, clock);
+        final Reservoir reservoir = reservoirFactory.create(1028, 0.015, clock);
         Timer timer = new Timer(reservoir, clock);
 
         Context context = timer.time();
@@ -160,7 +201,7 @@ public class ExponentiallyDecayingReservoirTest {
     }
 
     @Test
-    public void multipleUpdatesAfterlongPeriodsOfInactivityShouldNotCorruptSamplingState () throws Exception {
+    public void multipleUpdatesAfterlongPeriodsOfInactivityShouldNotCorruptSamplingState() throws Exception {
         // This test illustrates the potential race condition in rescale that
         // can lead to a corrupt state.  Note that while this test uses updates
         // exclusively to trigger the race condition, two concurrent updates
@@ -171,9 +212,9 @@ public class ExponentiallyDecayingReservoirTest {
         // expanded.
 
         // Run the test several times.
-        for (int attempt=0; attempt < 10; attempt++) {
+        for (int attempt = 0; attempt < 10; attempt++) {
             final ManualClock clock = new ManualClock();
-            final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(10,
+            final Reservoir reservoir = reservoirFactory.create(10,
                     0.015,
                     clock);
 
@@ -183,27 +224,26 @@ public class ExponentiallyDecayingReservoirTest {
             final AtomicInteger threadUpdates = new AtomicInteger(0);
             final AtomicInteger testUpdates = new AtomicInteger(0);
 
-            final Thread thread = new Thread(new Runnable() {
-                @Override
-                public void run() {
-                    int previous = 0;
-                    while (running.get()) {
-                        // Wait for the test thread to update it's counter
-                        // before updaing the reservoir.
-                        int next;
-                        while (previous >= (next = testUpdates.get()))
-                            ; // spin lock
-
-                        previous = next;
+            final Thread thread = new Thread(() -> {
+                int previous = 0;
+                while (running.get()) {
+                    // Wait for the test thread to update it's counter
+                    // before updaing the reservoir.
+                    while (true) {
+                        int next = testUpdates.get();
+                        if (previous < next) {
+                            previous = next;
+                            break;
+                        }
+                    }
 
-                        // Update the reservoir.  This needs to occur at the
-                        // same time as the test thread's update.
-                        reservoir.update(1000);
+                    // Update the reservoir.  This needs to occur at the
+                    // same time as the test thread's update.
+                    reservoir.update(1000);
 
-                        // Signal the main thread; allows the next update
-                        // attempt to begin.
-                        threadUpdates.incrementAndGet();
-                    }
+                    // Signal the main thread; allows the next update
+                    // attempt to begin.
+                    threadUpdates.incrementAndGet();
                 }
             });
 
@@ -212,7 +252,7 @@ public class ExponentiallyDecayingReservoirTest {
             int sum = 0;
             int previous = -1;
             for (int i = 0; i < 100; i++) {
-                // Wait for 24 hours before attempting the next concurrent
+                // Wait for 15 hours before attempting the next concurrent
                 // update.  The delay here needs to be sufficiently long to
                 // overflow if an update attempt is allowed to add a value to
                 // the reservoir without rescaling.  Note that:
@@ -232,11 +272,13 @@ public class ExponentiallyDecayingReservoirTest {
                 reservoir.update(1000);
 
                 // Wait for the other thread to finish it's update.
-                int next;
-                while (previous >= (next = threadUpdates.get()))
-                    ; // spin lock
-
-                previous = next;
+                while (true) {
+                    int next = threadUpdates.get();
+                    if (previous < next) {
+                        previous = next;
+                        break;
+                    }
+                }
             }
 
             // Terminate the thread.
@@ -257,20 +299,20 @@ public class ExponentiallyDecayingReservoirTest {
     @Test
     public void spotLift() {
         final ManualClock clock = new ManualClock();
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(1000,
-                                                                                            0.015,
-                                                                                            clock);
+        final Reservoir reservoir = reservoirFactory.create(1000,
+                0.015,
+                clock);
 
         final int valuesRatePerMinute = 10;
         final int valuesIntervalMillis = (int) (TimeUnit.MINUTES.toMillis(1) / valuesRatePerMinute);
         // mode 1: steady regime for 120 minutes
-        for (int i = 0; i < 120*valuesRatePerMinute; i++) {
+        for (int i = 0; i < 120 * valuesRatePerMinute; i++) {
             reservoir.update(177);
             clock.addMillis(valuesIntervalMillis);
         }
 
         // switching to mode 2: 10 minutes more with the same rate, but larger value
-        for (int i = 0; i < 10*valuesRatePerMinute; i++) {
+        for (int i = 0; i < 10 * valuesRatePerMinute; i++) {
             reservoir.update(9999);
             clock.addMillis(valuesIntervalMillis);
         }
@@ -283,20 +325,20 @@ public class ExponentiallyDecayingReservoirTest {
     @Test
     public void spotFall() {
         final ManualClock clock = new ManualClock();
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(1000,
-                                                                                            0.015,
-                                                                                            clock);
+        final Reservoir reservoir = reservoirFactory.create(1000,
+                0.015,
+                clock);
 
         final int valuesRatePerMinute = 10;
         final int valuesIntervalMillis = (int) (TimeUnit.MINUTES.toMillis(1) / valuesRatePerMinute);
         // mode 1: steady regime for 120 minutes
-        for (int i = 0; i < 120*valuesRatePerMinute; i++) {
+        for (int i = 0; i < 120 * valuesRatePerMinute; i++) {
             reservoir.update(9998);
             clock.addMillis(valuesIntervalMillis);
         }
 
         // switching to mode 2: 10 minutes more with the same rate, but smaller value
-        for (int i = 0; i < 10*valuesRatePerMinute; i++) {
+        for (int i = 0; i < 10 * valuesRatePerMinute; i++) {
             reservoir.update(178);
             clock.addMillis(valuesIntervalMillis);
         }
@@ -309,9 +351,9 @@ public class ExponentiallyDecayingReservoirTest {
     @Test
     public void quantiliesShouldBeBasedOnWeights() {
         final ManualClock clock = new ManualClock();
-        final ExponentiallyDecayingReservoir reservoir = new ExponentiallyDecayingReservoir(1000,
-                                                                                            0.015,
-                                                                                            clock);
+        final Reservoir reservoir = reservoirFactory.create(1000,
+                0.015,
+                clock);
         for (int i = 0; i < 40; i++) {
             reservoir.update(177);
         }
@@ -334,7 +376,35 @@ public class ExponentiallyDecayingReservoirTest {
                 .isEqualTo(9999);
     }
 
-    private static void assertAllValuesBetween(ExponentiallyDecayingReservoir reservoir,
+    @Test
+    public void clockWrapShouldNotRescale() {
+        // First verify the test works as expected given low values
+        testShortPeriodShouldNotRescale(0);
+        // Now revalidate using an edge case nanoTime value just prior to wrapping
+        testShortPeriodShouldNotRescale(Long.MAX_VALUE - TimeUnit.MINUTES.toNanos(30));
+    }
+
+    private void testShortPeriodShouldNotRescale(long startTimeNanos) {
+        final ManualClock clock = new ManualClock(startTimeNanos);
+        final Reservoir reservoir = reservoirFactory.create(10, 1, clock);
+
+        reservoir.update(1000);
+        assertThat(reservoir.getSnapshot().size()).isEqualTo(1);
+
+        assertAllValuesBetween(reservoir, 1000, 1001);
+
+        // wait for 10 millis and take snapshot.
+        // this should not trigger a rescale. Note that the number of samples will be reduced to 0
+        // because scaling factor equal to zero will remove all existing entries after rescale.
+        clock.addSeconds(20 * 60);
+        Snapshot snapshot = reservoir.getSnapshot();
+        assertThat(snapshot.getMax()).isEqualTo(1000);
+        assertThat(snapshot.getMean()).isEqualTo(1000);
+        assertThat(snapshot.getMedian()).isEqualTo(1000);
+        assertThat(snapshot.size()).isEqualTo(1);
+    }
+
+    private static void assertAllValuesBetween(Reservoir reservoir,
                                                double min,
                                                double max) {
         for (double i : reservoir.getSnapshot().getValues()) {
diff --git a/metrics-core/src/test/java/com/codahale/metrics/HistogramTest.java b/metrics-core/src/test/java/com/codahale/metrics/HistogramTest.java
index fa39326..17529eb 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/HistogramTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/HistogramTest.java
@@ -3,14 +3,16 @@ package com.codahale.metrics;
 import org.junit.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 public class HistogramTest {
     private final Reservoir reservoir = mock(Reservoir.class);
     private final Histogram histogram = new Histogram(reservoir);
 
     @Test
-    public void updatesTheCountOnUpdates() throws Exception {
+    public void updatesTheCountOnUpdates() {
         assertThat(histogram.getCount())
                 .isZero();
 
@@ -21,7 +23,7 @@ public class HistogramTest {
     }
 
     @Test
-    public void returnsTheSnapshotFromTheReservoir() throws Exception {
+    public void returnsTheSnapshotFromTheReservoir() {
         final Snapshot snapshot = mock(Snapshot.class);
         when(reservoir.getSnapshot()).thenReturn(snapshot);
 
diff --git a/metrics-core/src/test/java/com/codahale/metrics/InstrumentedExecutorServiceTest.java b/metrics-core/src/test/java/com/codahale/metrics/InstrumentedExecutorServiceTest.java
index 4e8eebd..76e026b 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/InstrumentedExecutorServiceTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/InstrumentedExecutorServiceTest.java
@@ -1,13 +1,19 @@
 package com.codahale.metrics;
 
 import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.time.Duration;
+import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.ForkJoinPool;
 import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -15,31 +21,53 @@ import static org.assertj.core.api.Assertions.assertThat;
 public class InstrumentedExecutorServiceTest {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(InstrumentedExecutorServiceTest.class);
+    private ExecutorService executor;
+    private MetricRegistry registry;
+    private InstrumentedExecutorService instrumentedExecutorService;
+    private Meter submitted;
+    private Counter running;
+    private Meter completed;
+    private Timer duration;
+    private Timer idle;
 
-    private final ExecutorService executor = Executors.newCachedThreadPool();
-    private final MetricRegistry registry = new MetricRegistry();
-    private final InstrumentedExecutorService instrumentedExecutorService = new InstrumentedExecutorService(executor, registry, "xs");
+    @Before
+    public void setup() {
+        executor = Executors.newCachedThreadPool();
+        registry = new MetricRegistry();
+        instrumentedExecutorService = new InstrumentedExecutorService(executor, registry, "xs");
+        submitted = registry.meter("xs.submitted");
+        running = registry.counter("xs.running");
+        completed = registry.meter("xs.completed");
+        duration = registry.timer("xs.duration");
+        idle = registry.timer("xs.idle");
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        instrumentedExecutorService.shutdown();
+        if (!instrumentedExecutorService.awaitTermination(2, TimeUnit.SECONDS)) {
+            LOGGER.error("InstrumentedExecutorService did not terminate.");
+        }
+    }
 
     @Test
-    public void reportsTasksInformation() throws Exception {
-        final Meter submitted = registry.meter("xs.submitted");
-        final Counter running = registry.counter("xs.running");
-        final Meter completed = registry.meter("xs.completed");
-        final Timer duration = registry.timer("xs.duration");
+    public void reportsTasksInformationForRunnable() throws Exception {
 
         assertThat(submitted.getCount()).isEqualTo(0);
         assertThat(running.getCount()).isEqualTo(0);
         assertThat(completed.getCount()).isEqualTo(0);
         assertThat(duration.getCount()).isEqualTo(0);
+        assertThat(idle.getCount()).isEqualTo(0);
 
-        Future<?> theFuture = instrumentedExecutorService.submit(new Runnable() {
-            public void run() {
-                assertThat(submitted.getCount()).isEqualTo(1);
-                assertThat(running.getCount()).isEqualTo(1);
-                assertThat(completed.getCount()).isEqualTo(0);
-                assertThat(duration.getCount()).isEqualTo(0);
-	    }
-	});
+        Runnable runnable = () -> {
+            assertThat(submitted.getCount()).isEqualTo(1);
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isEqualTo(0);
+            assertThat(duration.getCount()).isEqualTo(0);
+            assertThat(idle.getCount()).isEqualTo(1);
+        };
+
+        Future<?> theFuture = instrumentedExecutorService.submit(runnable);
 
         theFuture.get();
 
@@ -48,14 +76,143 @@ public class InstrumentedExecutorServiceTest {
         assertThat(completed.getCount()).isEqualTo(1);
         assertThat(duration.getCount()).isEqualTo(1);
         assertThat(duration.getSnapshot().size()).isEqualTo(1);
+        assertThat(idle.getCount()).isEqualTo(1);
+        assertThat(idle.getSnapshot().size()).isEqualTo(1);
     }
 
-    @After
-    public void tearDown() throws Exception {
-        instrumentedExecutorService.shutdown();
-        if (!instrumentedExecutorService.awaitTermination(2, TimeUnit.SECONDS)) {
-            LOGGER.error("InstrumentedExecutorService did not terminate.");
-        }
+    @Test
+    public void reportsTasksInformationForCallable() throws Exception {
+
+        assertThat(submitted.getCount()).isEqualTo(0);
+        assertThat(running.getCount()).isEqualTo(0);
+        assertThat(completed.getCount()).isEqualTo(0);
+        assertThat(duration.getCount()).isEqualTo(0);
+        assertThat(idle.getCount()).isEqualTo(0);
+
+        Callable<Void> callable = () -> {
+            assertThat(submitted.getCount()).isEqualTo(1);
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isEqualTo(0);
+            assertThat(duration.getCount()).isEqualTo(0);
+            assertThat(idle.getCount()).isEqualTo(1);
+            return null;
+        };
+
+        Future<?> theFuture = instrumentedExecutorService.submit(callable);
+
+        theFuture.get();
+
+        assertThat(submitted.getCount()).isEqualTo(1);
+        assertThat(running.getCount()).isEqualTo(0);
+        assertThat(completed.getCount()).isEqualTo(1);
+        assertThat(duration.getCount()).isEqualTo(1);
+        assertThat(duration.getSnapshot().size()).isEqualTo(1);
+        assertThat(idle.getCount()).isEqualTo(1);
+        assertThat(idle.getSnapshot().size()).isEqualTo(1);
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    public void reportsTasksInformationForThreadPoolExecutor() throws Exception {
+        executor = new ThreadPoolExecutor(4, 16,
+                0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(32));
+        instrumentedExecutorService = new InstrumentedExecutorService(executor, registry, "tp");
+        submitted = registry.meter("tp.submitted");
+        running = registry.counter("tp.running");
+        completed = registry.meter("tp.completed");
+        duration = registry.timer("tp.duration");
+        idle = registry.timer("tp.idle");
+        final Gauge<Integer> poolSize = (Gauge<Integer>) registry.getGauges().get("tp.pool.size");
+        final Gauge<Integer> poolCoreSize = (Gauge<Integer>) registry.getGauges().get("tp.pool.core");
+        final Gauge<Integer> poolMaxSize = (Gauge<Integer>) registry.getGauges().get("tp.pool.max");
+        final Gauge<Integer> tasksActive = (Gauge<Integer>) registry.getGauges().get("tp.tasks.active");
+        final Gauge<Long> tasksCompleted = (Gauge<Long>) registry.getGauges().get("tp.tasks.completed");
+        final Gauge<Integer> tasksQueued = (Gauge<Integer>) registry.getGauges().get("tp.tasks.queued");
+        final Gauge<Integer> tasksCapacityRemaining = (Gauge<Integer>) registry.getGauges().get("tp.tasks.capacity");
+
+        assertThat(submitted.getCount()).isEqualTo(0);
+        assertThat(running.getCount()).isEqualTo(0);
+        assertThat(completed.getCount()).isEqualTo(0);
+        assertThat(duration.getCount()).isEqualTo(0);
+        assertThat(idle.getCount()).isEqualTo(0);
+        assertThat(poolSize.getValue()).isEqualTo(0);
+        assertThat(poolCoreSize.getValue()).isEqualTo(4);
+        assertThat(poolMaxSize.getValue()).isEqualTo(16);
+        assertThat(tasksActive.getValue()).isEqualTo(0);
+        assertThat(tasksCompleted.getValue()).isEqualTo(0L);
+        assertThat(tasksQueued.getValue()).isEqualTo(0);
+        assertThat(tasksCapacityRemaining.getValue()).isEqualTo(32);
+
+        Runnable runnable = () -> {
+            assertThat(submitted.getCount()).isEqualTo(1);
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isEqualTo(0);
+            assertThat(duration.getCount()).isEqualTo(0);
+            assertThat(idle.getCount()).isEqualTo(1);
+            assertThat(tasksActive.getValue()).isEqualTo(1);
+            assertThat(tasksQueued.getValue()).isEqualTo(0);
+        };
+
+        Future<?> theFuture = instrumentedExecutorService.submit(runnable);
+
+        assertThat(theFuture).succeedsWithin(Duration.ofSeconds(5L));
+
+        assertThat(submitted.getCount()).isEqualTo(1);
+        assertThat(running.getCount()).isEqualTo(0);
+        assertThat(completed.getCount()).isEqualTo(1);
+        assertThat(duration.getCount()).isEqualTo(1);
+        assertThat(duration.getSnapshot().size()).isEqualTo(1);
+        assertThat(idle.getCount()).isEqualTo(1);
+        assertThat(idle.getSnapshot().size()).isEqualTo(1);
+        assertThat(poolSize.getValue()).isEqualTo(1);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void reportsTasksInformationForForkJoinPool() throws Exception {
+        executor = Executors.newWorkStealingPool(4);
+        instrumentedExecutorService = new InstrumentedExecutorService(executor, registry, "fjp");
+        submitted = registry.meter("fjp.submitted");
+        running = registry.counter("fjp.running");
+        completed = registry.meter("fjp.completed");
+        duration = registry.timer("fjp.duration");
+        idle = registry.timer("fjp.idle");
+        final Gauge<Long> tasksStolen = (Gauge<Long>) registry.getGauges().get("fjp.tasks.stolen");
+        final Gauge<Long> tasksQueued = (Gauge<Long>) registry.getGauges().get("fjp.tasks.queued");
+        final Gauge<Integer> threadsActive = (Gauge<Integer>) registry.getGauges().get("fjp.threads.active");
+        final Gauge<Integer> threadsRunning = (Gauge<Integer>) registry.getGauges().get("fjp.threads.running");
+
+        assertThat(submitted.getCount()).isEqualTo(0);
+        assertThat(running.getCount()).isEqualTo(0);
+        assertThat(completed.getCount()).isEqualTo(0);
+        assertThat(duration.getCount()).isEqualTo(0);
+        assertThat(idle.getCount()).isEqualTo(0);
+        assertThat(tasksStolen.getValue()).isEqualTo(0L);
+        assertThat(tasksQueued.getValue()).isEqualTo(0L);
+        assertThat(threadsActive.getValue()).isEqualTo(0);
+        assertThat(threadsRunning.getValue()).isEqualTo(0);
+
+        Runnable runnable = () -> {
+            assertThat(submitted.getCount()).isEqualTo(1);
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isEqualTo(0);
+            assertThat(duration.getCount()).isEqualTo(0);
+            assertThat(idle.getCount()).isEqualTo(1);
+            assertThat(tasksQueued.getValue()).isEqualTo(0L);
+            assertThat(threadsActive.getValue()).isEqualTo(1);
+            assertThat(threadsRunning.getValue()).isEqualTo(1);
+        };
+
+        Future<?> theFuture = instrumentedExecutorService.submit(runnable);
+
+        assertThat(theFuture).succeedsWithin(Duration.ofSeconds(5L));
+
+        assertThat(submitted.getCount()).isEqualTo(1);
+        assertThat(running.getCount()).isEqualTo(0);
+        assertThat(completed.getCount()).isEqualTo(1);
+        assertThat(duration.getCount()).isEqualTo(1);
+        assertThat(duration.getSnapshot().size()).isEqualTo(1);
+        assertThat(idle.getCount()).isEqualTo(1);
+        assertThat(idle.getSnapshot().size()).isEqualTo(1);
+    }
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/InstrumentedScheduledExecutorServiceTest.java b/metrics-core/src/test/java/com/codahale/metrics/InstrumentedScheduledExecutorServiceTest.java
index 9e9a246..f8ab4fd 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/InstrumentedScheduledExecutorServiceTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/InstrumentedScheduledExecutorServiceTest.java
@@ -5,7 +5,12 @@ import org.junit.Test;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.concurrent.*;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -16,16 +21,16 @@ public class InstrumentedScheduledExecutorServiceTest {
     private final MetricRegistry registry = new MetricRegistry();
     private final InstrumentedScheduledExecutorService instrumentedScheduledExecutor = new InstrumentedScheduledExecutorService(scheduledExecutor, registry, "xs");
 
-    final Meter submitted = registry.meter("xs.submitted");
+    private final Meter submitted = registry.meter("xs.submitted");
 
-    final Counter running = registry.counter("xs.running");
-    final Meter completed = registry.meter("xs.completed");
-    final Timer duration = registry.timer("xs.duration");
+    private final Counter running = registry.counter("xs.running");
+    private final Meter completed = registry.meter("xs.completed");
+    private final Timer duration = registry.timer("xs.duration");
 
-    final Meter scheduledOnce = registry.meter("xs.scheduled.once");
-    final Meter scheduledRepetitively = registry.meter("xs.scheduled.repetitively");
-    final Counter scheduledOverrun = registry.counter("xs.scheduled.overrun");
-    final Histogram percentOfPeriod = registry.histogram("xs.scheduled.percent-of-period");
+    private final Meter scheduledOnce = registry.meter("xs.scheduled.once");
+    private final Meter scheduledRepetitively = registry.meter("xs.scheduled.repetitively");
+    private final Counter scheduledOverrun = registry.counter("xs.scheduled.overrun");
+    private final Histogram percentOfPeriod = registry.histogram("xs.scheduled.percent-of-period");
 
     @Test
     public void testSubmitRunnable() throws Exception {
@@ -40,19 +45,17 @@ public class InstrumentedScheduledExecutorServiceTest {
         assertThat(scheduledOverrun.getCount()).isZero();
         assertThat(percentOfPeriod.getCount()).isZero();
 
-        Future<?> theFuture = instrumentedScheduledExecutor.submit(new Runnable() {
-            public void run() {
-                assertThat(submitted.getCount()).isEqualTo(1);
+        Future<?> theFuture = instrumentedScheduledExecutor.submit(() -> {
+            assertThat(submitted.getCount()).isEqualTo(1);
 
-                assertThat(running.getCount()).isEqualTo(1);
-                assertThat(completed.getCount()).isZero();
-                assertThat(duration.getCount()).isZero();
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isZero();
+            assertThat(duration.getCount()).isZero();
 
-                assertThat(scheduledOnce.getCount()).isZero();
-                assertThat(scheduledRepetitively.getCount()).isZero();
-                assertThat(scheduledOverrun.getCount()).isZero();
-                assertThat(percentOfPeriod.getCount()).isZero();
-            }
+            assertThat(scheduledOnce.getCount()).isZero();
+            assertThat(scheduledRepetitively.getCount()).isZero();
+            assertThat(scheduledOverrun.getCount()).isZero();
+            assertThat(percentOfPeriod.getCount()).isZero();
         });
 
         theFuture.get();
@@ -83,19 +86,17 @@ public class InstrumentedScheduledExecutorServiceTest {
         assertThat(scheduledOverrun.getCount()).isZero();
         assertThat(percentOfPeriod.getCount()).isZero();
 
-        ScheduledFuture<?> theFuture = instrumentedScheduledExecutor.schedule(new Runnable() {
-            public void run() {
-                assertThat(submitted.getCount()).isZero();
+        ScheduledFuture<?> theFuture = instrumentedScheduledExecutor.schedule(() -> {
+            assertThat(submitted.getCount()).isZero();
 
-                assertThat(running.getCount()).isEqualTo(1);
-                assertThat(completed.getCount()).isZero();
-                assertThat(duration.getCount()).isZero();
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isZero();
+            assertThat(duration.getCount()).isZero();
 
-                assertThat(scheduledOnce.getCount()).isEqualTo(1);
-                assertThat(scheduledRepetitively.getCount()).isZero();
-                assertThat(scheduledOverrun.getCount()).isZero();
-                assertThat(percentOfPeriod.getCount()).isZero();
-            }
+            assertThat(scheduledOnce.getCount()).isEqualTo(1);
+            assertThat(scheduledRepetitively.getCount()).isZero();
+            assertThat(scheduledOverrun.getCount()).isZero();
+            assertThat(percentOfPeriod.getCount()).isZero();
         }, 10L, TimeUnit.MILLISECONDS);
 
         theFuture.get();
@@ -128,21 +129,19 @@ public class InstrumentedScheduledExecutorServiceTest {
 
         final Object obj = new Object();
 
-        Future<Object> theFuture = instrumentedScheduledExecutor.submit(new Callable<Object>() {
-            public Object call() {
-                assertThat(submitted.getCount()).isEqualTo(1);
+        Future<Object> theFuture = instrumentedScheduledExecutor.submit(() -> {
+            assertThat(submitted.getCount()).isEqualTo(1);
 
-                assertThat(running.getCount()).isEqualTo(1);
-                assertThat(completed.getCount()).isZero();
-                assertThat(duration.getCount()).isZero();
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isZero();
+            assertThat(duration.getCount()).isZero();
 
-                assertThat(scheduledOnce.getCount()).isZero();
-                assertThat(scheduledRepetitively.getCount()).isZero();
-                assertThat(scheduledOverrun.getCount()).isZero();
-                assertThat(percentOfPeriod.getCount()).isZero();
+            assertThat(scheduledOnce.getCount()).isZero();
+            assertThat(scheduledRepetitively.getCount()).isZero();
+            assertThat(scheduledOverrun.getCount()).isZero();
+            assertThat(percentOfPeriod.getCount()).isZero();
 
-                return obj;
-            }
+            return obj;
         });
 
         assertThat(theFuture.get()).isEqualTo(obj);
@@ -175,21 +174,19 @@ public class InstrumentedScheduledExecutorServiceTest {
 
         final Object obj = new Object();
 
-        ScheduledFuture<Object> theFuture = instrumentedScheduledExecutor.schedule(new Callable<Object>() {
-            public Object call() {
-                assertThat(submitted.getCount()).isZero();
+        ScheduledFuture<Object> theFuture = instrumentedScheduledExecutor.schedule(() -> {
+            assertThat(submitted.getCount()).isZero();
 
-                assertThat(running.getCount()).isEqualTo(1);
-                assertThat(completed.getCount()).isZero();
-                assertThat(duration.getCount()).isZero();
+            assertThat(running.getCount()).isEqualTo(1);
+            assertThat(completed.getCount()).isZero();
+            assertThat(duration.getCount()).isZero();
 
-                assertThat(scheduledOnce.getCount()).isEqualTo(1);
-                assertThat(scheduledRepetitively.getCount()).isZero();
-                assertThat(scheduledOverrun.getCount()).isZero();
-                assertThat(percentOfPeriod.getCount()).isZero();
+            assertThat(scheduledOnce.getCount()).isEqualTo(1);
+            assertThat(scheduledRepetitively.getCount()).isZero();
+            assertThat(scheduledOverrun.getCount()).isZero();
+            assertThat(percentOfPeriod.getCount()).isZero();
 
-                return obj;
-            }
+            return obj;
         }, 10L, TimeUnit.MILLISECONDS);
 
         assertThat(theFuture.get()).isEqualTo(obj);
@@ -220,29 +217,26 @@ public class InstrumentedScheduledExecutorServiceTest {
         assertThat(scheduledOverrun.getCount()).isZero();
         assertThat(percentOfPeriod.getCount()).isZero();
 
-        final CountDownLatch countDownLatch = new CountDownLatch(1);
-        ScheduledFuture<?> theFuture = instrumentedScheduledExecutor.scheduleAtFixedRate(new Runnable() {
-            @Override
-            public void run() {
-                assertThat(submitted.getCount()).isZero();
+        CountDownLatch countDownLatch = new CountDownLatch(1);
+        ScheduledFuture<?> theFuture = instrumentedScheduledExecutor.scheduleAtFixedRate(() -> {
+            assertThat(submitted.getCount()).isZero();
 
-                assertThat(running.getCount()).isEqualTo(1);
+            assertThat(running.getCount()).isEqualTo(1);
 
-                assertThat(scheduledOnce.getCount()).isEqualTo(0);
-                assertThat(scheduledRepetitively.getCount()).isEqualTo(1);
+            assertThat(scheduledOnce.getCount()).isEqualTo(0);
+            assertThat(scheduledRepetitively.getCount()).isEqualTo(1);
 
-                try {
-                    TimeUnit.MILLISECONDS.sleep(50);
-                } catch (InterruptedException ex) {
-                    Thread.currentThread().interrupt();
-                }
-                countDownLatch.countDown();
+            try {
+                TimeUnit.MILLISECONDS.sleep(50);
+            } catch (InterruptedException ex) {
+                Thread.currentThread().interrupt();
             }
+            countDownLatch.countDown();
         }, 10L, 10L, TimeUnit.MILLISECONDS);
         TimeUnit.MILLISECONDS.sleep(100); // Give some time for the task to be run
         countDownLatch.await(5, TimeUnit.SECONDS); // Don't cancel until it didn't complete once
         theFuture.cancel(true);
-        TimeUnit.MILLISECONDS.sleep(100);         // Wait while the task is cancelled
+        TimeUnit.MILLISECONDS.sleep(200);         // Wait while the task is cancelled
 
         assertThat(submitted.getCount()).isZero();
 
@@ -270,30 +264,27 @@ public class InstrumentedScheduledExecutorServiceTest {
         assertThat(scheduledOverrun.getCount()).isZero();
         assertThat(percentOfPeriod.getCount()).isZero();
 
-        final CountDownLatch countDownLatch = new CountDownLatch(1);
-        ScheduledFuture<?> theFuture = instrumentedScheduledExecutor.scheduleWithFixedDelay(new Runnable() {
-            @Override
-            public void run() {
-                assertThat(submitted.getCount()).isZero();
+        CountDownLatch countDownLatch = new CountDownLatch(1);
+        ScheduledFuture<?> theFuture = instrumentedScheduledExecutor.scheduleWithFixedDelay(() -> {
+            assertThat(submitted.getCount()).isZero();
 
-                assertThat(running.getCount()).isEqualTo(1);
+            assertThat(running.getCount()).isEqualTo(1);
 
-                assertThat(scheduledOnce.getCount()).isEqualTo(0);
-                assertThat(scheduledRepetitively.getCount()).isEqualTo(1);
+            assertThat(scheduledOnce.getCount()).isEqualTo(0);
+            assertThat(scheduledRepetitively.getCount()).isEqualTo(1);
 
-                try {
-                    TimeUnit.MILLISECONDS.sleep(50);
-                } catch (InterruptedException ex) {
-                    Thread.currentThread().interrupt();
-                }
-                countDownLatch.countDown();
+            try {
+                TimeUnit.MILLISECONDS.sleep(50);
+            } catch (InterruptedException ex) {
+                Thread.currentThread().interrupt();
             }
+            countDownLatch.countDown();
         }, 10L, 10L, TimeUnit.MILLISECONDS);
 
         TimeUnit.MILLISECONDS.sleep(100);
         countDownLatch.await(5, TimeUnit.SECONDS);
         theFuture.cancel(true);
-        TimeUnit.MILLISECONDS.sleep(100);
+        TimeUnit.MILLISECONDS.sleep(200);
 
         assertThat(submitted.getCount()).isZero();
 
diff --git a/metrics-core/src/test/java/com/codahale/metrics/InstrumentedThreadFactoryTest.java b/metrics-core/src/test/java/com/codahale/metrics/InstrumentedThreadFactoryTest.java
index 387569c..61325f0 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/InstrumentedThreadFactoryTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/InstrumentedThreadFactoryTest.java
@@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -22,38 +23,14 @@ public class InstrumentedThreadFactoryTest {
     /**
      * Tests all parts of the InstrumentedThreadFactory except for termination since that
      * is currently difficult to do without race conditions.
-     * 
      * TODO: Try not using real threads in a unit test?
      */
     @Test
     public void reportsThreadInformation() throws Exception {
-        final CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
-        final Object lock = new Object();
+        final CountDownLatch allTasksAreCreated = new CountDownLatch(THREAD_COUNT);
+        final CountDownLatch allTasksAreCounted = new CountDownLatch(1);
         final AtomicInteger interrupted = new AtomicInteger();
 
-        /*
-         * Implements a runnable that notifies a latch after locking 'lock'.
-         * This asserts that all threads have to enter the critical block before the
-         * testing thread notifies all.
-         *
-         * We have to do this to guarantee that the thread pool has 10 LIVE threads
-         * before we check the 'created' Meter.
-         */
-        Runnable fastOne = new Runnable() {
-            @Override
-            public void run() {
-                synchronized (lock) {
-                    latch.countDown();
-
-                    try {
-                        lock.wait();
-                    } catch (InterruptedException e) {
-                        interrupted.incrementAndGet();
-                    }
-                }
-            }
-        };
-
         Meter created = registry.meter("factory.created");
         Meter terminated = registry.meter("factory.terminated");
 
@@ -62,15 +39,24 @@ public class InstrumentedThreadFactoryTest {
 
         // generate demand so the executor service creates the threads through our factory.
         for (int i = 0; i < THREAD_COUNT + 1; i++) {
-            executor.submit(fastOne);
+            Future<?> t = executor.submit(() -> {
+                allTasksAreCreated.countDown();
+
+                // This asserts that all threads have wait wail the testing thread notifies all.
+                // We have to do this to guarantee that the thread pool has 10 LIVE threads
+                // before we check the 'created' Meter.
+                try {
+                    allTasksAreCounted.await();
+                } catch (InterruptedException e) {
+                    interrupted.incrementAndGet();
+                    Thread.currentThread().interrupt();
+                }
+            });
+            assertThat(t).isNotNull();
         }
 
-        latch.await(1, TimeUnit.SECONDS);
-
-        synchronized (lock) {
-            // wake up all threads.
-            lock.notifyAll();
-        }
+        allTasksAreCreated.await(1, TimeUnit.SECONDS);
+        allTasksAreCounted.countDown();
 
         assertThat(created.getCount()).isEqualTo(10);
         assertThat(terminated.getCount()).isEqualTo(0);
diff --git a/metrics-core/src/test/java/com/codahale/metrics/ManualClock.java b/metrics-core/src/test/java/com/codahale/metrics/ManualClock.java
index b7a8f44..0310921 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/ManualClock.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/ManualClock.java
@@ -3,7 +3,17 @@ package com.codahale.metrics;
 import java.util.concurrent.TimeUnit;
 
 public class ManualClock extends Clock {
-    long ticksInNanos = 0;
+    private final long initialTicksInNanos;
+    long ticksInNanos;
+
+    public ManualClock(long initialTicksInNanos) {
+        this.initialTicksInNanos = initialTicksInNanos;
+        this.ticksInNanos = initialTicksInNanos;
+    }
+
+    public ManualClock() {
+        this(0L);
+    }
 
     public synchronized void addNanos(long nanos) {
         ticksInNanos += nanos;
@@ -12,11 +22,11 @@ public class ManualClock extends Clock {
     public synchronized void addSeconds(long seconds) {
         ticksInNanos += TimeUnit.SECONDS.toNanos(seconds);
     }
-    
+
     public synchronized void addMillis(long millis) {
         ticksInNanos += TimeUnit.MILLISECONDS.toNanos(millis);
     }
-    
+
     public synchronized void addHours(long hours) {
         ticksInNanos += TimeUnit.HOURS.toNanos(hours);
     }
@@ -28,7 +38,7 @@ public class ManualClock extends Clock {
 
     @Override
     public synchronized long getTime() {
-        return TimeUnit.NANOSECONDS.toMillis(ticksInNanos);
+        return TimeUnit.NANOSECONDS.toMillis(ticksInNanos - initialTicksInNanos);
     }
-    
+
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/MeterApproximationTest.java b/metrics-core/src/test/java/com/codahale/metrics/MeterApproximationTest.java
index d008823..eb3560b 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/MeterApproximationTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/MeterApproximationTest.java
@@ -17,66 +17,66 @@ public class MeterApproximationTest {
 
     @Parameters
     public static Collection<Object[]> ratesPerMinute() {
-        Object[][] data = new Object[][] { 
-            { 15 }, { 60 }, { 600 }, { 6000 }
+        Object[][] data = new Object[][]{
+                {15}, {60}, {600}, {6000}
         };
         return Arrays.asList(data);
-    }    
-    
+    }
+
     private final long ratePerMinute;
-    
+
     public MeterApproximationTest(long ratePerMinute) {
         this.ratePerMinute = ratePerMinute;
     }
-    
+
     @Test
-    public void controlMeter1MinuteMeanApproximation() throws Exception {
+    public void controlMeter1MinuteMeanApproximation() {
         final Meter meter = simulateMetronome(
                 62934, TimeUnit.MILLISECONDS,
                 3, TimeUnit.MINUTES);
 
-        assertThat(meter.getOneMinuteRate()*60.0)
-                .isEqualTo(ratePerMinute, offset(0.1*ratePerMinute));
+        assertThat(meter.getOneMinuteRate() * 60.0)
+                .isEqualTo(ratePerMinute, offset(0.1 * ratePerMinute));
     }
 
     @Test
-    public void controlMeter5MinuteMeanApproximation() throws Exception {
+    public void controlMeter5MinuteMeanApproximation() {
         final Meter meter = simulateMetronome(
                 62934, TimeUnit.MILLISECONDS,
                 13, TimeUnit.MINUTES);
 
-        assertThat(meter.getFiveMinuteRate()*60.0)
-                .isEqualTo(ratePerMinute, offset(0.1*ratePerMinute));
+        assertThat(meter.getFiveMinuteRate() * 60.0)
+                .isEqualTo(ratePerMinute, offset(0.1 * ratePerMinute));
     }
 
     @Test
-    public void controlMeter15MinuteMeanApproximation() throws Exception {
+    public void controlMeter15MinuteMeanApproximation() {
         final Meter meter = simulateMetronome(
                 62934, TimeUnit.MILLISECONDS,
                 38, TimeUnit.MINUTES);
 
-        assertThat(meter.getFifteenMinuteRate()*60.0)
-                .isEqualTo(ratePerMinute, offset(0.1*ratePerMinute));
+        assertThat(meter.getFifteenMinuteRate() * 60.0)
+                .isEqualTo(ratePerMinute, offset(0.1 * ratePerMinute));
     }
 
     private Meter simulateMetronome(
             long introDelay, TimeUnit introDelayUnit,
             long duration, TimeUnit durationUnit) {
-        
+
         final ManualClock clock = new ManualClock();
         final Meter meter = new Meter(clock);
-        
+
         clock.addNanos(introDelayUnit.toNanos(introDelay));
-        
+
         final long endTick = clock.getTick() + durationUnit.toNanos(duration);
         final long marksIntervalInNanos = TimeUnit.MINUTES.toNanos(1) / ratePerMinute;
-        
+
         while (clock.getTick() <= endTick) {
             clock.addNanos(marksIntervalInNanos);
             meter.mark();
         }
-        
+
         return meter;
     }
-    
+
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/MeterTest.java b/metrics-core/src/test/java/com/codahale/metrics/MeterTest.java
index da4345b..a1c4935 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/MeterTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/MeterTest.java
@@ -21,7 +21,7 @@ public class MeterTest {
     }
 
     @Test
-    public void startsOutWithNoRatesOrCount() throws Exception {
+    public void startsOutWithNoRatesOrCount() {
         assertThat(meter.getCount())
                 .isZero();
 
@@ -39,7 +39,7 @@ public class MeterTest {
     }
 
     @Test
-    public void marksEventsAndUpdatesRatesAndCount() throws Exception {
+    public void marksEventsAndUpdatesRatesAndCount() {
         meter.mark();
         meter.mark(2);
 
diff --git a/metrics-core/src/test/java/com/codahale/metrics/MetricFilterTest.java b/metrics-core/src/test/java/com/codahale/metrics/MetricFilterTest.java
index 978e7ef..3ae0363 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/MetricFilterTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/MetricFilterTest.java
@@ -7,8 +7,32 @@ import static org.mockito.Mockito.mock;
 
 public class MetricFilterTest {
     @Test
-    public void theAllFilterMatchesAllMetrics() throws Exception {
+    public void theAllFilterMatchesAllMetrics() {
         assertThat(MetricFilter.ALL.matches("", mock(Metric.class)))
                 .isTrue();
     }
+
+    @Test
+    public void theStartsWithFilterMatches() {
+        assertThat(MetricFilter.startsWith("foo").matches("foo.bar", mock(Metric.class)))
+                .isTrue();
+        assertThat(MetricFilter.startsWith("foo").matches("bar.foo", mock(Metric.class)))
+                .isFalse();
+    }
+
+    @Test
+    public void theEndsWithFilterMatches() {
+        assertThat(MetricFilter.endsWith("foo").matches("foo.bar", mock(Metric.class)))
+                .isFalse();
+        assertThat(MetricFilter.endsWith("foo").matches("bar.foo", mock(Metric.class)))
+                .isTrue();
+    }
+
+    @Test
+    public void theContainsFilterMatches() {
+        assertThat(MetricFilter.contains("foo").matches("bar.foo.bar", mock(Metric.class)))
+                .isTrue();
+        assertThat(MetricFilter.contains("foo").matches("bar.bar", mock(Metric.class)))
+                .isFalse();
+    }
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryListenerTest.java b/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryListenerTest.java
index 2d579ef..b7ef32b 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryListenerTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryListenerTest.java
@@ -3,10 +3,9 @@ package com.codahale.metrics;
 import org.junit.Test;
 
 import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 public class MetricRegistryListenerTest {
-    private final Gauge gauge = mock(Gauge.class);
     private final Counter counter = mock(Counter.class);
     private final Histogram histogram = mock(Histogram.class);
     private final Meter meter = mock(Meter.class);
@@ -16,42 +15,42 @@ public class MetricRegistryListenerTest {
     };
 
     @Test
-    public void noOpsOnGaugeAdded() throws Exception {
-        listener.onGaugeAdded("blah", gauge);
-
-        verifyZeroInteractions(gauge);
+    public void noOpsOnGaugeAdded() {
+        listener.onGaugeAdded("blah", () -> {
+            throw new RuntimeException("Should not be called");
+        });
     }
 
     @Test
-    public void noOpsOnCounterAdded() throws Exception {
+    public void noOpsOnCounterAdded() {
         listener.onCounterAdded("blah", counter);
 
-        verifyZeroInteractions(counter);
+        verifyNoInteractions(counter);
     }
 
     @Test
-    public void noOpsOnHistogramAdded() throws Exception {
+    public void noOpsOnHistogramAdded() {
         listener.onHistogramAdded("blah", histogram);
 
-        verifyZeroInteractions(histogram);
+        verifyNoInteractions(histogram);
     }
 
     @Test
-    public void noOpsOnMeterAdded() throws Exception {
+    public void noOpsOnMeterAdded() {
         listener.onMeterAdded("blah", meter);
 
-        verifyZeroInteractions(meter);
+        verifyNoInteractions(meter);
     }
 
     @Test
-    public void noOpsOnTimerAdded() throws Exception {
+    public void noOpsOnTimerAdded() {
         listener.onTimerAdded("blah", timer);
 
-        verifyZeroInteractions(timer);
+        verifyNoInteractions(timer);
     }
 
     @Test
-    public void doesNotExplodeWhenMetricsAreRemoved() throws Exception {
+    public void doesNotExplodeWhenMetricsAreRemoved() {
         listener.onGaugeRemoved("blah");
         listener.onCounterRemoved("blah");
         listener.onHistogramRemoved("blah");
diff --git a/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryTest.java b/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryTest.java
index f314c19..cbc86ac 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/MetricRegistryTest.java
@@ -1,33 +1,38 @@
 package com.codahale.metrics;
 
+import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 import static com.codahale.metrics.MetricRegistry.name;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.entry;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 
 public class MetricRegistryTest {
     private final MetricRegistryListener listener = mock(MetricRegistryListener.class);
     private final MetricRegistry registry = new MetricRegistry();
-    @SuppressWarnings("unchecked")
-    private final Gauge<String> gauge = mock(Gauge.class);
+    private final Gauge<String> gauge = () -> "";
+    private final SettableGauge<String> settableGauge = new DefaultSettableGauge<>("");
     private final Counter counter = mock(Counter.class);
     private final Histogram histogram = mock(Histogram.class);
     private final Meter meter = mock(Meter.class);
     private final Timer timer = mock(Timer.class);
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         registry.addListener(listener);
     }
 
     @Test
-    public void registeringAGaugeTriggersANotification() throws Exception {
+    public void registeringAGaugeTriggersANotification() {
         assertThat(registry.register("thing", gauge))
                 .isEqualTo(gauge);
 
@@ -35,7 +40,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void removingAGaugeTriggersANotification() throws Exception {
+    public void removingAGaugeTriggersANotification() {
         registry.register("thing", gauge);
 
         assertThat(registry.remove("thing"))
@@ -45,7 +50,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registeringACounterTriggersANotification() throws Exception {
+    public void registeringACounterTriggersANotification() {
         assertThat(registry.register("thing", counter))
                 .isEqualTo(counter);
 
@@ -53,7 +58,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingACounterRegistersAndReusesTheCounter() throws Exception {
+    public void accessingACounterRegistersAndReusesTheCounter() {
         final Counter counter1 = registry.counter("thing");
         final Counter counter2 = registry.counter("thing");
 
@@ -64,13 +69,8 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingACustomCounterRegistersAndReusesTheCounter() throws Exception {
-        final MetricRegistry.MetricSupplier<Counter> supplier = new MetricRegistry.MetricSupplier<Counter>() {
-            @Override
-            public Counter newMetric() {
-                return counter;
-            }
-        };
+    public void accessingACustomCounterRegistersAndReusesTheCounter() {
+        final MetricRegistry.MetricSupplier<Counter> supplier = () -> counter;
         final Counter counter1 = registry.counter("thing", supplier);
         final Counter counter2 = registry.counter("thing", supplier);
 
@@ -82,7 +82,7 @@ public class MetricRegistryTest {
 
 
     @Test
-    public void removingACounterTriggersANotification() throws Exception {
+    public void removingACounterTriggersANotification() {
         registry.register("thing", counter);
 
         assertThat(registry.remove("thing"))
@@ -92,7 +92,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registeringAHistogramTriggersANotification() throws Exception {
+    public void registeringAHistogramTriggersANotification() {
         assertThat(registry.register("thing", histogram))
                 .isEqualTo(histogram);
 
@@ -100,7 +100,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingAHistogramRegistersAndReusesIt() throws Exception {
+    public void accessingAHistogramRegistersAndReusesIt() {
         final Histogram histogram1 = registry.histogram("thing");
         final Histogram histogram2 = registry.histogram("thing");
 
@@ -111,13 +111,8 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingACustomHistogramRegistersAndReusesIt() throws Exception {
-        final MetricRegistry.MetricSupplier<Histogram> supplier = new MetricRegistry.MetricSupplier<Histogram>() {
-            @Override
-            public Histogram newMetric() {
-                return histogram;
-            }
-        };
+    public void accessingACustomHistogramRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Histogram> supplier = () -> histogram;
         final Histogram histogram1 = registry.histogram("thing", supplier);
         final Histogram histogram2 = registry.histogram("thing", supplier);
 
@@ -128,7 +123,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void removingAHistogramTriggersANotification() throws Exception {
+    public void removingAHistogramTriggersANotification() {
         registry.register("thing", histogram);
 
         assertThat(registry.remove("thing"))
@@ -138,7 +133,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registeringAMeterTriggersANotification() throws Exception {
+    public void registeringAMeterTriggersANotification() {
         assertThat(registry.register("thing", meter))
                 .isEqualTo(meter);
 
@@ -146,7 +141,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingAMeterRegistersAndReusesIt() throws Exception {
+    public void accessingAMeterRegistersAndReusesIt() {
         final Meter meter1 = registry.meter("thing");
         final Meter meter2 = registry.meter("thing");
 
@@ -157,13 +152,8 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingACustomMeterRegistersAndReusesIt() throws Exception {
-        final MetricRegistry.MetricSupplier<Meter> supplier = new MetricRegistry.MetricSupplier<Meter>() {
-            @Override
-            public Meter newMetric() {
-                return meter;
-            }
-        };
+    public void accessingACustomMeterRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Meter> supplier = () -> meter;
         final Meter meter1 = registry.meter("thing", supplier);
         final Meter meter2 = registry.meter("thing", supplier);
 
@@ -173,8 +163,8 @@ public class MetricRegistryTest {
         verify(listener).onMeterAdded("thing", meter1);
     }
 
-        @Test
-    public void removingAMeterTriggersANotification() throws Exception {
+    @Test
+    public void removingAMeterTriggersANotification() {
         registry.register("thing", meter);
 
         assertThat(registry.remove("thing"))
@@ -184,7 +174,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registeringATimerTriggersANotification() throws Exception {
+    public void registeringATimerTriggersANotification() {
         assertThat(registry.register("thing", timer))
                 .isEqualTo(timer);
 
@@ -192,7 +182,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingATimerRegistersAndReusesIt() throws Exception {
+    public void accessingATimerRegistersAndReusesIt() {
         final Timer timer1 = registry.timer("thing");
         final Timer timer2 = registry.timer("thing");
 
@@ -203,13 +193,8 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingACustomTimerRegistersAndReusesIt() throws Exception {
-        final MetricRegistry.MetricSupplier<Timer> supplier = new MetricRegistry.MetricSupplier<Timer>() {
-            @Override
-            public Timer newMetric() {
-                return timer;
-            }
-        };
+    public void accessingACustomTimerRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Timer> supplier = () -> timer;
         final Timer timer1 = registry.timer("thing", supplier);
         final Timer timer2 = registry.timer("thing", supplier);
 
@@ -221,7 +206,7 @@ public class MetricRegistryTest {
 
 
     @Test
-    public void removingATimerTriggersANotification() throws Exception {
+    public void removingATimerTriggersANotification() {
         registry.register("thing", timer);
 
         assertThat(registry.remove("thing"))
@@ -231,13 +216,43 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void accessingACustomGaugeRegistersAndReusesIt() throws Exception {
-        final MetricRegistry.MetricSupplier<Gauge> supplier = new MetricRegistry.MetricSupplier<Gauge>() {
-            @Override
-            public Gauge newMetric() {
-                return gauge;
-            }
-        };
+    public void accessingASettableGaugeRegistersAndReusesIt() {
+        final SettableGauge<String> gauge1 = registry.gauge("thing");
+        gauge1.setValue("Test");
+        final Gauge<String> gauge2 = registry.gauge("thing");
+
+        assertThat(gauge1).isSameAs(gauge2);
+        assertThat(gauge2.getValue()).isEqualTo("Test");
+
+        verify(listener).onGaugeAdded("thing", gauge1);
+    }
+
+    @Test
+    public void accessingAnExistingGaugeReusesIt() {
+        final Gauge<String> gauge1 = registry.gauge("thing", () -> () -> "string-gauge");
+        final Gauge<String> gauge2 = registry.gauge("thing", () -> new DefaultSettableGauge<>("settable-gauge"));
+
+        assertThat(gauge1).isSameAs(gauge2);
+        assertThat(gauge2.getValue()).isEqualTo("string-gauge");
+
+        verify(listener).onGaugeAdded("thing", gauge1);
+    }
+
+    @Test
+    public void accessingAnExistingSettableGaugeReusesIt() {
+        final Gauge<String> gauge1 = registry.gauge("thing", () -> new DefaultSettableGauge<>("settable-gauge"));
+        final Gauge<String> gauge2 = registry.gauge("thing");
+
+        assertThat(gauge1).isSameAs(gauge2);
+        assertThat(gauge2.getValue()).isEqualTo("settable-gauge");
+
+        verify(listener).onGaugeAdded("thing", gauge1);
+    }
+
+    @Test
+    @SuppressWarnings("rawtypes")
+    public void accessingACustomGaugeRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Gauge> supplier = () -> gauge;
         final Gauge gauge1 = registry.gauge("thing", supplier);
         final Gauge gauge2 = registry.gauge("thing", supplier);
 
@@ -247,9 +262,20 @@ public class MetricRegistryTest {
         verify(listener).onGaugeAdded("thing", gauge1);
     }
 
+    @Test
+    public void settableGaugeIsTreatedLikeAGauge() {
+        final MetricRegistry.MetricSupplier<SettableGauge<String>> supplier = () -> settableGauge;
+        final SettableGauge<String> gauge1 = registry.gauge("thing", supplier);
+        final SettableGauge<String> gauge2 = registry.gauge("thing", supplier);
+
+        assertThat(gauge1)
+                .isSameAs(gauge2);
+
+        verify(listener).onGaugeAdded("thing", gauge1);
+    }
 
     @Test
-    public void addingAListenerWithExistingMetricsCatchesItUp() throws Exception {
+    public void addingAListenerWithExistingMetricsCatchesItUp() {
         registry.register("gauge", gauge);
         registry.register("counter", counter);
         registry.register("histogram", histogram);
@@ -267,7 +293,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void aRemovedListenerDoesNotReceiveUpdates() throws Exception {
+    public void aRemovedListenerDoesNotReceiveUpdates() {
         registry.register("gauge", gauge);
         registry.removeListener(listener);
         registry.register("gauge2", gauge);
@@ -276,7 +302,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void hasAMapOfRegisteredGauges() throws Exception {
+    public void hasAMapOfRegisteredGauges() {
         registry.register("gauge", gauge);
 
         assertThat(registry.getGauges())
@@ -284,7 +310,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void hasAMapOfRegisteredCounters() throws Exception {
+    public void hasAMapOfRegisteredCounters() {
         registry.register("counter", counter);
 
         assertThat(registry.getCounters())
@@ -292,7 +318,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void hasAMapOfRegisteredHistograms() throws Exception {
+    public void hasAMapOfRegisteredHistograms() {
         registry.register("histogram", histogram);
 
         assertThat(registry.getHistograms())
@@ -300,7 +326,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void hasAMapOfRegisteredMeters() throws Exception {
+    public void hasAMapOfRegisteredMeters() {
         registry.register("meter", meter);
 
         assertThat(registry.getMeters())
@@ -308,7 +334,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void hasAMapOfRegisteredTimers() throws Exception {
+    public void hasAMapOfRegisteredTimers() {
         registry.register("timer", timer);
 
         assertThat(registry.getTimers())
@@ -316,7 +342,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void hasASetOfRegisteredMetricNames() throws Exception {
+    public void hasASetOfRegisteredMetricNames() {
         registry.register("gauge", gauge);
         registry.register("counter", counter);
         registry.register("histogram", histogram);
@@ -328,15 +354,12 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registersMultipleMetrics() throws Exception {
-        final MetricSet metrics = new MetricSet() {
-            @Override
-            public Map<String, Metric> getMetrics() {
-                final Map<String, Metric> metrics = new HashMap<String, Metric>();
-                metrics.put("gauge", gauge);
-                metrics.put("counter", counter);
-                return metrics;
-            }
+    public void registersMultipleMetrics() {
+        final MetricSet metrics = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("gauge", gauge);
+            m.put("counter", counter);
+            return m;
         };
 
         registry.registerAll(metrics);
@@ -346,15 +369,12 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registersMultipleMetricsWithAPrefix() throws Exception {
-        final MetricSet metrics = new MetricSet() {
-            @Override
-            public Map<String, Metric> getMetrics() {
-                final Map<String, Metric> metrics = new HashMap<String, Metric>();
-                metrics.put("gauge", gauge);
-                metrics.put("counter", counter);
-                return metrics;
-            }
+    public void registersMultipleMetricsWithAPrefix() {
+        final MetricSet metrics = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("gauge", gauge);
+            m.put("counter", counter);
+            return m;
         };
 
         registry.register("my", metrics);
@@ -364,24 +384,18 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registersRecursiveMetricSets() throws Exception {
-        final MetricSet inner = new MetricSet() {
-            @Override
-            public Map<String, Metric> getMetrics() {
-                final Map<String, Metric> metrics = new HashMap<String, Metric>();
-                metrics.put("gauge", gauge);
-                return metrics;
-            }
+    public void registersRecursiveMetricSets() {
+        final MetricSet inner = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("gauge", gauge);
+            return m;
         };
 
-        final MetricSet outer = new MetricSet() {
-            @Override
-            public Map<String, Metric> getMetrics() {
-                final Map<String, Metric> metrics = new HashMap<String, Metric>();
-                metrics.put("inner", inner);
-                metrics.put("counter", counter);
-                return metrics;
-            }
+        final MetricSet outer = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("inner", inner);
+            m.put("counter", counter);
+            return m;
         };
 
         registry.register("my", outer);
@@ -391,7 +405,7 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void registersMetricsFromAnotherRegistry() throws Exception {
+    public void registersMetricsFromAnotherRegistry() {
         MetricRegistry other = new MetricRegistry();
         other.register("gauge", gauge);
         registry.register("nested", other);
@@ -399,57 +413,52 @@ public class MetricRegistryTest {
     }
 
     @Test
-    public void concatenatesStringsToFormADottedName() throws Exception {
+    public void concatenatesStringsToFormADottedName() {
         assertThat(name("one", "two", "three"))
                 .isEqualTo("one.two.three");
     }
 
     @Test
     @SuppressWarnings("NullArgumentToVariableArgMethod")
-    public void elidesNullValuesFromNamesWhenOnlyOneNullPassedIn() throws Exception {
-        assertThat(name("one", (String)null))
+    public void elidesNullValuesFromNamesWhenOnlyOneNullPassedIn() {
+        assertThat(name("one", (String) null))
                 .isEqualTo("one");
     }
 
     @Test
-    public void elidesNullValuesFromNamesWhenManyNullsPassedIn() throws Exception {
+    public void elidesNullValuesFromNamesWhenManyNullsPassedIn() {
         assertThat(name("one", null, null))
                 .isEqualTo("one");
     }
 
     @Test
-    public void elidesNullValuesFromNamesWhenNullAndNotNullPassedIn() throws Exception {
+    public void elidesNullValuesFromNamesWhenNullAndNotNullPassedIn() {
         assertThat(name("one", null, "three"))
                 .isEqualTo("one.three");
     }
 
     @Test
-    public void elidesEmptyStringsFromNames() throws Exception {
+    public void elidesEmptyStringsFromNames() {
         assertThat(name("one", "", "three"))
                 .isEqualTo("one.three");
     }
 
     @Test
-    public void concatenatesClassNamesWithStringsToFormADottedName() throws Exception {
+    public void concatenatesClassNamesWithStringsToFormADottedName() {
         assertThat(name(MetricRegistryTest.class, "one", "two"))
                 .isEqualTo("com.codahale.metrics.MetricRegistryTest.one.two");
     }
 
     @Test
-    public void concatenatesClassesWithoutCanonicalNamesWithStrings() throws Exception {
-        final Gauge<String> g = new Gauge<String>() {
-            @Override
-            public String getValue() {
-                return null;
-            }
-        };
+    public void concatenatesClassesWithoutCanonicalNamesWithStrings() {
+        final Gauge<String> g = () -> null;
 
         assertThat(name(g.getClass(), "one", "two"))
-                .isEqualTo("com.codahale.metrics.MetricRegistryTest$10.one.two");
+                .matches("com\\.codahale\\.metrics\\.MetricRegistryTest.+?\\.one\\.two");
     }
 
     @Test
-    public void removesMetricsMatchingAFilter() throws Exception {
+    public void removesMetricsMatchingAFilter() {
         registry.timer("timer-1");
         registry.timer("timer-2");
         registry.histogram("histogram-1");
@@ -457,12 +466,7 @@ public class MetricRegistryTest {
         assertThat(registry.getNames())
                 .contains("timer-1", "timer-2", "histogram-1");
 
-        registry.removeMatching(new MetricFilter() {
-            @Override
-            public boolean matches(String name, Metric metric) {
-                return name.endsWith("1");
-            }
-        });
+        registry.removeMatching((name, metric) -> name.endsWith("1"));
 
         assertThat(registry.getNames())
                 .doesNotContain("timer-1", "histogram-1");
@@ -472,4 +476,168 @@ public class MetricRegistryTest {
         verify(listener).onTimerRemoved("timer-1");
         verify(listener).onHistogramRemoved("histogram-1");
     }
+
+    @Test
+    public void addingChildMetricAfterRegister() {
+        MetricRegistry parent = new MetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        parent.register("child", child);
+        child.counter("test-2");
+
+        Set<String> parentMetrics = parent.getMetrics().keySet();
+        Set<String> childMetrics = child.getMetrics().keySet();
+
+        assertThat(parentMetrics)
+                .isEqualTo(childMetrics.stream().map(m -> "child." + m).collect(Collectors.toSet()));
+    }
+
+    @Test
+    public void addingMultipleChildMetricsAfterRegister() {
+        MetricRegistry parent = new MetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        child.counter("test-2");
+        parent.register("child", child);
+        child.counter("test-3");
+        child.counter("test-4");
+
+        Set<String> parentMetrics = parent.getMetrics().keySet();
+        Set<String> childMetrics = child.getMetrics().keySet();
+
+        assertThat(parentMetrics)
+                .isEqualTo(childMetrics.stream().map(m -> "child." + m).collect(Collectors.toSet()));
+    }
+
+    @Test
+    public void addingDeepChildMetricsAfterRegister() {
+        MetricRegistry parent = new MetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+        MetricRegistry deepChild = new MetricRegistry();
+
+        deepChild.counter("test-1");
+        child.register("deep-child", deepChild);
+        deepChild.counter("test-2");
+
+        child.counter("test-3");
+        parent.register("child", child);
+        child.counter("test-4");
+
+        deepChild.counter("test-5");
+
+        Set<String> parentMetrics = parent.getMetrics().keySet();
+        Set<String> childMetrics = child.getMetrics().keySet();
+        Set<String> deepChildMetrics = deepChild.getMetrics().keySet();
+
+        assertThat(parentMetrics)
+                .isEqualTo(childMetrics.stream().map(m -> "child." + m).collect(Collectors.toSet()));
+
+        assertThat(childMetrics)
+                .containsAll(deepChildMetrics.stream().map(m -> "deep-child." + m).collect(Collectors.toSet()));
+
+        assertThat(deepChildMetrics.size()).isEqualTo(3);
+        assertThat(childMetrics.size()).isEqualTo(5);
+    }
+
+    @Test
+    public void removingChildMetricAfterRegister() {
+        MetricRegistry parent = new MetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        parent.register("child", child);
+        child.counter("test-2");
+
+        child.remove("test-1");
+
+        Set<String> parentMetrics = parent.getMetrics().keySet();
+        Set<String> childMetrics = child.getMetrics().keySet();
+
+        assertThat(parentMetrics)
+                .isEqualTo(childMetrics.stream().map(m -> "child." + m).collect(Collectors.toSet()));
+
+        assertThat(childMetrics).doesNotContain("test-1");
+    }
+
+    @Test
+    public void removingMultipleChildMetricsAfterRegister() {
+        MetricRegistry parent = new MetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        child.counter("test-2");
+        parent.register("child", child);
+        child.counter("test-3");
+        child.counter("test-4");
+
+        child.remove("test-1");
+        child.remove("test-3");
+
+        Set<String> parentMetrics = parent.getMetrics().keySet();
+        Set<String> childMetrics = child.getMetrics().keySet();
+
+        assertThat(parentMetrics)
+                .isEqualTo(childMetrics.stream().map(m -> "child." + m).collect(Collectors.toSet()));
+
+        assertThat(childMetrics).doesNotContain("test-1", "test-3");
+    }
+
+    @Test
+    public void removingDeepChildMetricsAfterRegister() {
+        MetricRegistry parent = new MetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+        MetricRegistry deepChild = new MetricRegistry();
+
+        deepChild.counter("test-1");
+        child.register("deep-child", deepChild);
+        deepChild.counter("test-2");
+
+        child.counter("test-3");
+        parent.register("child", child);
+        child.counter("test-4");
+
+        deepChild.remove("test-2");
+
+        Set<String> parentMetrics = parent.getMetrics().keySet();
+        Set<String> childMetrics = child.getMetrics().keySet();
+        Set<String> deepChildMetrics = deepChild.getMetrics().keySet();
+
+        assertThat(parentMetrics)
+                .isEqualTo(childMetrics.stream().map(m -> "child." + m).collect(Collectors.toSet()));
+
+        assertThat(childMetrics)
+                .containsAll(deepChildMetrics.stream().map(m -> "deep-child." + m).collect(Collectors.toSet()));
+
+        assertThat(deepChildMetrics).doesNotContain("test-2");
+
+        assertThat(deepChildMetrics.size()).isEqualTo(1);
+        assertThat(childMetrics.size()).isEqualTo(3);
+    }
+
+    @Test
+    public void registerNullMetric() {
+        MetricRegistry registry = new MetricRegistry();
+        try {
+            registry.register("any_name", null);
+            Assert.fail("NullPointerException must be thrown !!!");
+        } catch (NullPointerException e) {
+            Assert.assertEquals("metric == null", e.getMessage());
+        }
+    }
+
+    @Test
+    public void infersGaugeType() {
+        Gauge<Long> gauge = registry.registerGauge("gauge", () -> 10_000_000_000L);
+
+        assertThat(gauge.getValue()).isEqualTo(10_000_000_000L);
+    }
+
+    @Test
+    public void registersGaugeAsLambda() {
+        registry.registerGauge("gauge", () -> 3.14);
+
+        assertThat(registry.gauge("gauge").getValue()).isEqualTo(3.14);
+    }
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/NoopMetricRegistryTest.java b/metrics-core/src/test/java/com/codahale/metrics/NoopMetricRegistryTest.java
new file mode 100644
index 0000000..700c2a0
--- /dev/null
+++ b/metrics-core/src/test/java/com/codahale/metrics/NoopMetricRegistryTest.java
@@ -0,0 +1,495 @@
+package com.codahale.metrics;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+public class NoopMetricRegistryTest {
+    private final MetricRegistryListener listener = mock(MetricRegistryListener.class);
+    private final NoopMetricRegistry registry = new NoopMetricRegistry();
+    private final Gauge<String> gauge = () -> "";
+    private final Counter counter = mock(Counter.class);
+    private final Histogram histogram = mock(Histogram.class);
+    private final Meter meter = mock(Meter.class);
+    private final Timer timer = mock(Timer.class);
+
+    @Before
+    public void setUp() {
+        registry.addListener(listener);
+    }
+
+    @Test
+    public void registeringAGaugeTriggersNoNotification() {
+        assertThat(registry.register("thing", gauge)).isEqualTo(gauge);
+
+        verify(listener, never()).onGaugeAdded("thing", gauge);
+    }
+
+    @Test
+    public void removingAGaugeTriggersNoNotification() {
+        registry.register("thing", gauge);
+
+        assertThat(registry.remove("thing")).isFalse();
+
+        verify(listener, never()).onGaugeRemoved("thing");
+    }
+
+    @Test
+    public void registeringACounterTriggersNoNotification() {
+        assertThat(registry.register("thing", counter)).isEqualTo(counter);
+
+        verify(listener, never()).onCounterAdded("thing", counter);
+    }
+
+    @Test
+    public void accessingACounterRegistersAndReusesTheCounter() {
+        final Counter counter1 = registry.counter("thing");
+        final Counter counter2 = registry.counter("thing");
+
+        assertThat(counter1).isExactlyInstanceOf(NoopMetricRegistry.NoopCounter.class);
+        assertThat(counter2).isExactlyInstanceOf(NoopMetricRegistry.NoopCounter.class);
+        assertThat(counter1).isSameAs(counter2);
+
+        verify(listener, never()).onCounterAdded("thing", counter1);
+    }
+
+    @Test
+    public void accessingACustomCounterRegistersAndReusesTheCounter() {
+        final MetricRegistry.MetricSupplier<Counter> supplier = () -> counter;
+        final Counter counter1 = registry.counter("thing", supplier);
+        final Counter counter2 = registry.counter("thing", supplier);
+
+        assertThat(counter1).isExactlyInstanceOf(NoopMetricRegistry.NoopCounter.class);
+        assertThat(counter2).isExactlyInstanceOf(NoopMetricRegistry.NoopCounter.class);
+        assertThat(counter1).isSameAs(counter2);
+
+        verify(listener, never()).onCounterAdded("thing", counter1);
+    }
+
+
+    @Test
+    public void removingACounterTriggersNoNotification() {
+        registry.register("thing", counter);
+
+        assertThat(registry.remove("thing")).isFalse();
+
+        verify(listener, never()).onCounterRemoved("thing");
+    }
+
+    @Test
+    public void registeringAHistogramTriggersNoNotification() {
+        assertThat(registry.register("thing", histogram)).isEqualTo(histogram);
+
+        verify(listener, never()).onHistogramAdded("thing", histogram);
+    }
+
+    @Test
+    public void accessingAHistogramRegistersAndReusesIt() {
+        final Histogram histogram1 = registry.histogram("thing");
+        final Histogram histogram2 = registry.histogram("thing");
+
+        assertThat(histogram1).isExactlyInstanceOf(NoopMetricRegistry.NoopHistogram.class);
+        assertThat(histogram2).isExactlyInstanceOf(NoopMetricRegistry.NoopHistogram.class);
+        assertThat(histogram1).isSameAs(histogram2);
+
+        verify(listener, never()).onHistogramAdded("thing", histogram1);
+    }
+
+    @Test
+    public void accessingACustomHistogramRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Histogram> supplier = () -> histogram;
+        final Histogram histogram1 = registry.histogram("thing", supplier);
+        final Histogram histogram2 = registry.histogram("thing", supplier);
+
+        assertThat(histogram1).isExactlyInstanceOf(NoopMetricRegistry.NoopHistogram.class);
+        assertThat(histogram2).isExactlyInstanceOf(NoopMetricRegistry.NoopHistogram.class);
+        assertThat(histogram1).isSameAs(histogram2);
+
+        verify(listener, never()).onHistogramAdded("thing", histogram1);
+    }
+
+    @Test
+    public void removingAHistogramTriggersNoNotification() {
+        registry.register("thing", histogram);
+
+        assertThat(registry.remove("thing")).isFalse();
+
+        verify(listener, never()).onHistogramRemoved("thing");
+    }
+
+    @Test
+    public void registeringAMeterTriggersNoNotification() {
+        assertThat(registry.register("thing", meter)).isEqualTo(meter);
+
+        verify(listener, never()).onMeterAdded("thing", meter);
+    }
+
+    @Test
+    public void accessingAMeterRegistersAndReusesIt() {
+        final Meter meter1 = registry.meter("thing");
+        final Meter meter2 = registry.meter("thing");
+
+        assertThat(meter1).isExactlyInstanceOf(NoopMetricRegistry.NoopMeter.class);
+        assertThat(meter2).isExactlyInstanceOf(NoopMetricRegistry.NoopMeter.class);
+        assertThat(meter1).isSameAs(meter2);
+
+        verify(listener, never()).onMeterAdded("thing", meter1);
+    }
+
+    @Test
+    public void accessingACustomMeterRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Meter> supplier = () -> meter;
+        final Meter meter1 = registry.meter("thing", supplier);
+        final Meter meter2 = registry.meter("thing", supplier);
+
+        assertThat(meter1).isExactlyInstanceOf(NoopMetricRegistry.NoopMeter.class);
+        assertThat(meter2).isExactlyInstanceOf(NoopMetricRegistry.NoopMeter.class);
+        assertThat(meter1).isSameAs(meter2);
+
+        verify(listener, never()).onMeterAdded("thing", meter1);
+    }
+
+    @Test
+    public void removingAMeterTriggersNoNotification() {
+        registry.register("thing", meter);
+
+        assertThat(registry.remove("thing")).isFalse();
+
+        verify(listener, never()).onMeterRemoved("thing");
+    }
+
+    @Test
+    public void registeringATimerTriggersNoNotification() {
+        assertThat(registry.register("thing", timer)).isEqualTo(timer);
+
+        verify(listener, never()).onTimerAdded("thing", timer);
+    }
+
+    @Test
+    public void accessingATimerRegistersAndReusesIt() {
+        final Timer timer1 = registry.timer("thing");
+        final Timer timer2 = registry.timer("thing");
+
+        assertThat(timer1).isExactlyInstanceOf(NoopMetricRegistry.NoopTimer.class);
+        assertThat(timer2).isExactlyInstanceOf(NoopMetricRegistry.NoopTimer.class);
+        assertThat(timer1).isSameAs(timer2);
+
+        verify(listener, never()).onTimerAdded("thing", timer1);
+    }
+
+    @Test
+    public void accessingACustomTimerRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Timer> supplier = () -> timer;
+        final Timer timer1 = registry.timer("thing", supplier);
+        final Timer timer2 = registry.timer("thing", supplier);
+
+        assertThat(timer1).isExactlyInstanceOf(NoopMetricRegistry.NoopTimer.class);
+        assertThat(timer2).isExactlyInstanceOf(NoopMetricRegistry.NoopTimer.class);
+        assertThat(timer1).isSameAs(timer2);
+
+        verify(listener, never()).onTimerAdded("thing", timer1);
+    }
+
+
+    @Test
+    public void removingATimerTriggersNoNotification() {
+        registry.register("thing", timer);
+
+        assertThat(registry.remove("thing")).isFalse();
+
+        verify(listener, never()).onTimerRemoved("thing");
+    }
+
+    @Test
+    public void accessingAGaugeRegistersAndReusesIt() {
+        final Gauge<Void> gauge1 = registry.gauge("thing");
+        final Gauge<Void> gauge2 = registry.gauge("thing");
+
+        assertThat(gauge1).isExactlyInstanceOf(NoopMetricRegistry.NoopGauge.class);
+        assertThat(gauge2).isExactlyInstanceOf(NoopMetricRegistry.NoopGauge.class);
+        assertThat(gauge1).isSameAs(gauge2);
+
+        verify(listener, never()).onGaugeAdded("thing", gauge1);
+    }
+
+    @Test
+    @SuppressWarnings("rawtypes")
+    public void accessingACustomGaugeRegistersAndReusesIt() {
+        final MetricRegistry.MetricSupplier<Gauge> supplier = () -> gauge;
+        final Gauge gauge1 = registry.gauge("thing", supplier);
+        final Gauge gauge2 = registry.gauge("thing", supplier);
+
+        assertThat(gauge1).isExactlyInstanceOf(NoopMetricRegistry.NoopGauge.class);
+        assertThat(gauge2).isExactlyInstanceOf(NoopMetricRegistry.NoopGauge.class);
+        assertThat(gauge1).isSameAs(gauge2);
+
+        verify(listener, never()).onGaugeAdded("thing", gauge1);
+    }
+
+
+    @Test
+    public void addingAListenerWithExistingMetricsDoesNotNotify() {
+        registry.register("gauge", gauge);
+        registry.register("counter", counter);
+        registry.register("histogram", histogram);
+        registry.register("meter", meter);
+        registry.register("timer", timer);
+
+        final MetricRegistryListener other = mock(MetricRegistryListener.class);
+        registry.addListener(other);
+
+        verify(other, never()).onGaugeAdded("gauge", gauge);
+        verify(other, never()).onCounterAdded("counter", counter);
+        verify(other, never()).onHistogramAdded("histogram", histogram);
+        verify(other, never()).onMeterAdded("meter", meter);
+        verify(other, never()).onTimerAdded("timer", timer);
+    }
+
+    @Test
+    public void aRemovedListenerDoesNotReceiveUpdates() {
+        registry.register("gauge", gauge);
+        registry.removeListener(listener);
+        registry.register("gauge2", gauge);
+
+        verify(listener, never()).onGaugeAdded("gauge2", gauge);
+    }
+
+    @Test
+    public void hasAMapOfRegisteredGauges() {
+        registry.register("gauge", gauge);
+
+        assertThat(registry.getGauges()).isEmpty();
+    }
+
+    @Test
+    public void hasAMapOfRegisteredCounters() {
+        registry.register("counter", counter);
+
+        assertThat(registry.getCounters()).isEmpty();
+    }
+
+    @Test
+    public void hasAMapOfRegisteredHistograms() {
+        registry.register("histogram", histogram);
+
+        assertThat(registry.getHistograms()).isEmpty();
+    }
+
+    @Test
+    public void hasAMapOfRegisteredMeters() {
+        registry.register("meter", meter);
+
+        assertThat(registry.getMeters()).isEmpty();
+    }
+
+    @Test
+    public void hasAMapOfRegisteredTimers() {
+        registry.register("timer", timer);
+
+        assertThat(registry.getTimers()).isEmpty();
+    }
+
+    @Test
+    public void hasASetOfRegisteredMetricNames() {
+        registry.register("gauge", gauge);
+        registry.register("counter", counter);
+        registry.register("histogram", histogram);
+        registry.register("meter", meter);
+        registry.register("timer", timer);
+
+        assertThat(registry.getNames()).isEmpty();
+    }
+
+    @Test
+    public void doesNotRegisterMultipleMetrics() {
+        final MetricSet metrics = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("gauge", gauge);
+            m.put("counter", counter);
+            return m;
+        };
+
+        registry.registerAll(metrics);
+
+        assertThat(registry.getNames()).isEmpty();
+    }
+
+    @Test
+    public void doesNotRegisterMultipleMetricsWithAPrefix() {
+        final MetricSet metrics = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("gauge", gauge);
+            m.put("counter", counter);
+            return m;
+        };
+
+        registry.register("my", metrics);
+
+        assertThat(registry.getNames()).isEmpty();
+    }
+
+    @Test
+    public void doesNotRegisterRecursiveMetricSets() {
+        final MetricSet inner = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("gauge", gauge);
+            return m;
+        };
+
+        final MetricSet outer = () -> {
+            final Map<String, Metric> m = new HashMap<>();
+            m.put("inner", inner);
+            m.put("counter", counter);
+            return m;
+        };
+
+        registry.register("my", outer);
+
+        assertThat(registry.getNames()).isEmpty();
+    }
+
+    @Test
+    public void doesNotRegisterMetricsFromAnotherRegistry() {
+        MetricRegistry other = new MetricRegistry();
+        other.register("gauge", gauge);
+        registry.register("nested", other);
+        assertThat(registry.getNames()).isEmpty();
+    }
+
+    @Test
+    public void removesMetricsMatchingAFilter() {
+        registry.timer("timer-1");
+        registry.timer("timer-2");
+        registry.histogram("histogram-1");
+
+        assertThat(registry.getNames()).isEmpty();
+
+        registry.removeMatching((name, metric) -> name.endsWith("1"));
+
+        assertThat(registry.getNames()).isEmpty();
+
+        verify(listener, never()).onTimerRemoved("timer-1");
+        verify(listener, never()).onHistogramRemoved("histogram-1");
+    }
+
+    @Test
+    public void addingChildMetricAfterRegister() {
+        MetricRegistry parent = new NoopMetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        parent.register("child", child);
+        child.counter("test-2");
+
+        assertThat(parent.getMetrics()).isEmpty();
+    }
+
+    @Test
+    public void addingMultipleChildMetricsAfterRegister() {
+        MetricRegistry parent = new NoopMetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        child.counter("test-2");
+        parent.register("child", child);
+        child.counter("test-3");
+        child.counter("test-4");
+
+        assertThat(parent.getMetrics()).isEmpty();
+    }
+
+    @Test
+    public void addingDeepChildMetricsAfterRegister() {
+        MetricRegistry parent = new NoopMetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+        MetricRegistry deepChild = new MetricRegistry();
+
+        deepChild.counter("test-1");
+        child.register("deep-child", deepChild);
+        deepChild.counter("test-2");
+
+        child.counter("test-3");
+        parent.register("child", child);
+        child.counter("test-4");
+
+        deepChild.counter("test-5");
+
+        assertThat(parent.getMetrics()).isEmpty();
+        assertThat(deepChild.getMetrics()).hasSize(3);
+        assertThat(child.getMetrics()).hasSize(5);
+    }
+
+    @Test
+    public void removingChildMetricAfterRegister() {
+        MetricRegistry parent = new NoopMetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        parent.register("child", child);
+        child.counter("test-2");
+
+        child.remove("test-1");
+
+        assertThat(parent.getMetrics()).isEmpty();
+        assertThat(child.getMetrics()).doesNotContainKey("test-1");
+    }
+
+    @Test
+    public void removingMultipleChildMetricsAfterRegister() {
+        MetricRegistry parent = new NoopMetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+
+        child.counter("test-1");
+        child.counter("test-2");
+        parent.register("child", child);
+        child.counter("test-3");
+        child.counter("test-4");
+
+        child.remove("test-1");
+        child.remove("test-3");
+
+        assertThat(parent.getMetrics()).isEmpty();
+        assertThat(child.getMetrics()).doesNotContainKeys("test-1", "test-3");
+    }
+
+    @Test
+    public void removingDeepChildMetricsAfterRegister() {
+        MetricRegistry parent = new NoopMetricRegistry();
+        MetricRegistry child = new MetricRegistry();
+        MetricRegistry deepChild = new MetricRegistry();
+
+        deepChild.counter("test-1");
+        child.register("deep-child", deepChild);
+        deepChild.counter("test-2");
+
+        child.counter("test-3");
+        parent.register("child", child);
+        child.counter("test-4");
+
+        deepChild.remove("test-2");
+
+        Set<String> childMetrics = child.getMetrics().keySet();
+        Set<String> deepChildMetrics = deepChild.getMetrics().keySet();
+
+        assertThat(parent.getMetrics()).isEmpty();
+        assertThat(deepChildMetrics).hasSize(1);
+        assertThat(childMetrics).hasSize(3);
+    }
+
+    @Test
+    public void registerNullMetric() {
+        MetricRegistry registry = new NoopMetricRegistry();
+        assertThatNullPointerException()
+                .isThrownBy(() -> registry.register("any_name", null))
+                .withMessage("metric == null");
+    }
+}
diff --git a/metrics-core/src/test/java/com/codahale/metrics/RatioGaugeTest.java b/metrics-core/src/test/java/com/codahale/metrics/RatioGaugeTest.java
index 9a9ccdb..b48fe3c 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/RatioGaugeTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/RatioGaugeTest.java
@@ -6,7 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 public class RatioGaugeTest {
     @Test
-    public void ratiosAreHumanReadable() throws Exception {
+    public void ratiosAreHumanReadable() {
         final RatioGauge.Ratio ratio = RatioGauge.Ratio.of(100, 200);
 
         assertThat(ratio.toString())
@@ -14,7 +14,7 @@ public class RatioGaugeTest {
     }
 
     @Test
-    public void calculatesTheRatioOfTheNumeratorToTheDenominator() throws Exception {
+    public void calculatesTheRatioOfTheNumeratorToTheDenominator() {
         final RatioGauge regular = new RatioGauge() {
             @Override
             protected Ratio getRatio() {
@@ -27,7 +27,7 @@ public class RatioGaugeTest {
     }
 
     @Test
-    public void handlesDivideByZeroIssues() throws Exception {
+    public void handlesDivideByZeroIssues() {
         final RatioGauge divByZero = new RatioGauge() {
             @Override
             protected Ratio getRatio() {
@@ -40,7 +40,7 @@ public class RatioGaugeTest {
     }
 
     @Test
-    public void handlesInfiniteDenominators() throws Exception {
+    public void handlesInfiniteDenominators() {
         final RatioGauge infinite = new RatioGauge() {
             @Override
             protected Ratio getRatio() {
@@ -53,7 +53,7 @@ public class RatioGaugeTest {
     }
 
     @Test
-    public void handlesNaNDenominators() throws Exception {
+    public void handlesNaNDenominators() {
         final RatioGauge nan = new RatioGauge() {
             @Override
             protected Ratio getRatio() {
diff --git a/metrics-core/src/test/java/com/codahale/metrics/ScheduledReporterTest.java b/metrics-core/src/test/java/com/codahale/metrics/ScheduledReporterTest.java
index ae6a483..cd53c3a 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/ScheduledReporterTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/ScheduledReporterTest.java
@@ -1,21 +1,32 @@
 package com.codahale.metrics;
 
 import org.junit.After;
+import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
 import java.util.SortedMap;
 import java.util.TreeMap;
-import java.util.concurrent.*;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 public class ScheduledReporterTest {
-    private final Gauge gauge = mock(Gauge.class);
+    private final Gauge<String> gauge = () -> "";
     private final Counter counter = mock(Counter.class);
     private final Histogram histogram = mock(Histogram.class);
     private final Meter meter = mock(Meter.class);
@@ -38,6 +49,7 @@ public class ScheduledReporterTest {
     private final ScheduledReporter[] reporters = new ScheduledReporter[] {reporter, reporterWithCustomExecutor, reporterWithExternallyManagedExecutor};
 
     @Before
+    @SuppressWarnings("unchecked")
     public void setUp() throws Exception {
         registry.register("gauge", gauge);
         registry.register("counter", counter);
@@ -54,16 +66,29 @@ public class ScheduledReporterTest {
         reporterWithNullExecutor.stop();
     }
 
+    @Test
+    public void createWithNullMetricRegistry() {
+        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
+        DummyReporter r = null;
+        try {
+            r = new DummyReporter(null, "example", MetricFilter.ALL, TimeUnit.SECONDS, TimeUnit.MILLISECONDS, executor);
+            Assert.fail("NullPointerException must be thrown !!!");
+        } catch (NullPointerException e) {
+            Assert.assertEquals("registry == null", e.getMessage());
+        } finally {
+            if (r != null) {
+                r.close();
+            }
+        }
+    }
+
     @Test
     public void pollsPeriodically() throws Exception {
-        final CountDownLatch latch = new CountDownLatch(2);
-        reporter.start(100, 100, TimeUnit.MILLISECONDS, new Runnable() {
-            @Override
-            public void run() {
-                if (latch.getCount() > 0) {
-                    reporter.report();
-                    latch.countDown();
-                }
+        CountDownLatch latch = new CountDownLatch(2);
+        reporter.start(100, 100, TimeUnit.MILLISECONDS, () -> {
+            if (latch.getCount() > 0) {
+                reporter.report();
+                latch.countDown();
             }
         });
         latch.await(5, TimeUnit.SECONDS);
@@ -81,7 +106,7 @@ public class ScheduledReporterTest {
     public void shouldUsePeriodAsInitialDelayIfNotSpecifiedOtherwise() throws Exception {
         reporterWithCustomMockExecutor.start(200, TimeUnit.MILLISECONDS);
 
-        verify(mockExecutor, times(1)).scheduleAtFixedRate(
+        verify(mockExecutor, times(1)).scheduleWithFixedDelay(
             any(Runnable.class), eq(200L), eq(200L), eq(TimeUnit.MILLISECONDS)
         );
     }
@@ -90,21 +115,18 @@ public class ScheduledReporterTest {
     public void shouldStartWithSpecifiedInitialDelay() throws Exception {
         reporterWithCustomMockExecutor.start(350, 100, TimeUnit.MILLISECONDS);
 
-        verify(mockExecutor).scheduleAtFixedRate(
+        verify(mockExecutor).scheduleWithFixedDelay(
             any(Runnable.class), eq(350L), eq(100L), eq(TimeUnit.MILLISECONDS)
         );
     }
 
     @Test
     public void shouldAutoCreateExecutorWhenItNull() throws Exception {
-        final CountDownLatch latch = new CountDownLatch(2);
-        reporterWithNullExecutor.start(100, 100, TimeUnit.MILLISECONDS, new Runnable() {
-            @Override
-            public void run() {
-                if (latch.getCount() > 0) {
-                    reporterWithNullExecutor.report();
-                    latch.countDown();
-                }
+        CountDownLatch latch = new CountDownLatch(2);
+        reporterWithNullExecutor.start(100, 100, TimeUnit.MILLISECONDS, () -> {
+            if (latch.getCount() > 0) {
+                reporterWithNullExecutor.report();
+                latch.countDown();
             }
         });
         latch.await(5, TimeUnit.SECONDS);
@@ -185,8 +207,53 @@ public class ScheduledReporterTest {
         assertEquals(2.0E-5, reporter.convertDuration(20), 0.0);
     }
 
+    @Test
+    public void shouldReportMetricsOnShutdown() throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        reporterWithNullExecutor.start(0, 10, TimeUnit.SECONDS, () -> {
+            if (latch.getCount() > 0) {
+                reporterWithNullExecutor.report();
+                latch.countDown();
+            }
+        });
+        latch.await(5, TimeUnit.SECONDS);
+        reporterWithNullExecutor.stop();
+
+        verify(reporterWithNullExecutor, times(2)).report(
+                map("gauge", gauge),
+                map("counter", counter),
+                map("histogram", histogram),
+                map("meter", meter),
+                map("timer", timer)
+        );
+    }
+
+    @Test
+    public void shouldRescheduleAfterReportFinish() throws Exception {
+        // the first report is triggered at T + 0.1 seconds and takes 0.8 seconds
+        // after the first report finishes at T + 0.9 seconds the next report is scheduled to run at T + 1.4 seconds
+        reporter.start(100, 500, TimeUnit.MILLISECONDS, () -> {
+            reporter.report();
+            try {
+                Thread.sleep(800);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        });
+
+        Thread.sleep(1_000);
+
+        verify(reporter, times(1)).report(
+                map("gauge", gauge),
+                map("counter", counter),
+                map("histogram", histogram),
+                map("meter", meter),
+                map("timer", timer)
+        );
+    }
+
     private <T> SortedMap<String, T> map(String name, T value) {
-        final SortedMap<String, T> map = new TreeMap<String, T>();
+        final SortedMap<String, T> map = new TreeMap<>();
         map.put(name, value);
         return map;
     }
@@ -195,19 +262,20 @@ public class ScheduledReporterTest {
 
         private AtomicInteger executionCount = new AtomicInteger();
 
-        public DummyReporter(MetricRegistry registry, String name, MetricFilter filter, TimeUnit rateUnit, TimeUnit durationUnit) {
+        DummyReporter(MetricRegistry registry, String name, MetricFilter filter, TimeUnit rateUnit, TimeUnit durationUnit) {
             super(registry, name, filter, rateUnit, durationUnit);
         }
 
-        public DummyReporter(MetricRegistry registry, String name, MetricFilter filter, TimeUnit rateUnit, TimeUnit durationUnit, ScheduledExecutorService executor) {
+        DummyReporter(MetricRegistry registry, String name, MetricFilter filter, TimeUnit rateUnit, TimeUnit durationUnit, ScheduledExecutorService executor) {
             super(registry, name, filter, rateUnit, durationUnit, executor);
         }
 
-        public DummyReporter(MetricRegistry registry, String name, MetricFilter filter, TimeUnit rateUnit, TimeUnit durationUnit, ScheduledExecutorService executor, boolean shutdownExecutorOnStop) {
+        DummyReporter(MetricRegistry registry, String name, MetricFilter filter, TimeUnit rateUnit, TimeUnit durationUnit, ScheduledExecutorService executor, boolean shutdownExecutorOnStop) {
             super(registry, name, filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop);
         }
 
         @Override
+        @SuppressWarnings("rawtypes")
         public void report(SortedMap<String, Gauge> gauges, SortedMap<String, Counter> counters, SortedMap<String, Histogram> histograms, SortedMap<String, Meter> meters, SortedMap<String, Timer> timers) {
             executionCount.incrementAndGet();
             // nothing doing!
diff --git a/metrics-core/src/test/java/com/codahale/metrics/SharedMetricRegistriesTest.java b/metrics-core/src/test/java/com/codahale/metrics/SharedMetricRegistriesTest.java
index e9637ac..2affff0 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/SharedMetricRegistriesTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/SharedMetricRegistriesTest.java
@@ -2,20 +2,25 @@ package com.codahale.metrics;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.Rule;
+import org.junit.rules.ExpectedException;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
 import java.util.concurrent.atomic.AtomicReference;
 
 public class SharedMetricRegistriesTest {
+    @Rule
+    public ExpectedException exception = ExpectedException.none();
+
     @Before
-    public void setUp() throws Exception {
-        SharedMetricRegistries.setDefaultRegistryName(new AtomicReference<String>());
+    public void setUp() {
+        SharedMetricRegistries.setDefaultRegistryName(new AtomicReference<>());
         SharedMetricRegistries.clear();
     }
 
     @Test
-    public void memorizesRegistriesByName() throws Exception {
+    public void memorizesRegistriesByName() {
         final MetricRegistry one = SharedMetricRegistries.getOrCreate("one");
         final MetricRegistry two = SharedMetricRegistries.getOrCreate("one");
 
@@ -24,7 +29,7 @@ public class SharedMetricRegistriesTest {
     }
 
     @Test
-    public void hasASetOfNames() throws Exception {
+    public void hasASetOfNames() {
         SharedMetricRegistries.getOrCreate("one");
 
         assertThat(SharedMetricRegistries.names())
@@ -32,7 +37,7 @@ public class SharedMetricRegistriesTest {
     }
 
     @Test
-    public void removesRegistries() throws Exception {
+    public void removesRegistries() {
         final MetricRegistry one = SharedMetricRegistries.getOrCreate("one");
         SharedMetricRegistries.remove("one");
 
@@ -45,7 +50,7 @@ public class SharedMetricRegistriesTest {
     }
 
     @Test
-    public void clearsRegistries() throws Exception {
+    public void clearsRegistries() {
         SharedMetricRegistries.getOrCreate("one");
         SharedMetricRegistries.getOrCreate("two");
 
@@ -56,17 +61,14 @@ public class SharedMetricRegistriesTest {
     }
 
     @Test
-    public void errorsWhenDefaultUnset() throws Exception {
-        try {
-            SharedMetricRegistries.getDefault();
-        } catch (final Exception e) {
-            assertThat(e).isInstanceOf(IllegalStateException.class);
-            assertThat(e.getMessage()).isEqualTo("Default registry name has not been set.");
-        }
+    public void errorsWhenDefaultUnset() {
+        exception.expect(IllegalStateException.class);
+        exception.expectMessage("Default registry name has not been set.");
+        SharedMetricRegistries.getDefault();
     }
 
     @Test
-    public void createsDefaultRegistries() throws Exception {
+    public void createsDefaultRegistries() {
         final String defaultName = "default";
         final MetricRegistry registry = SharedMetricRegistries.setDefault(defaultName);
         assertThat(registry).isNotNull();
@@ -75,18 +77,15 @@ public class SharedMetricRegistriesTest {
     }
 
     @Test
-    public void errorsWhenDefaultAlreadySet() throws Exception {
-        try {
-            SharedMetricRegistries.setDefault("foobah");
-            SharedMetricRegistries.setDefault("borg");
-        } catch (final Exception e) {
-            assertThat(e).isInstanceOf(IllegalStateException.class);
-            assertThat(e.getMessage()).isEqualTo("Default metric registry name is already set.");
-        }
+    public void errorsWhenDefaultAlreadySet() {
+        SharedMetricRegistries.setDefault("foobah");
+        exception.expect(IllegalStateException.class);
+        exception.expectMessage("Default metric registry name is already set.");
+        SharedMetricRegistries.setDefault("borg");
     }
 
     @Test
-    public void setsDefaultExistingRegistries() throws Exception {
+    public void setsDefaultExistingRegistries() {
         final String defaultName = "default";
         final MetricRegistry registry = new MetricRegistry();
         assertThat(SharedMetricRegistries.setDefault(defaultName, registry)).isEqualTo(registry);
diff --git a/metrics-core/src/test/java/com/codahale/metrics/SimpleSettableGaugeTest.java b/metrics-core/src/test/java/com/codahale/metrics/SimpleSettableGaugeTest.java
new file mode 100644
index 0000000..70a0245
--- /dev/null
+++ b/metrics-core/src/test/java/com/codahale/metrics/SimpleSettableGaugeTest.java
@@ -0,0 +1,28 @@
+package com.codahale.metrics;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class SimpleSettableGaugeTest {
+
+    @Test
+    public void defaultValue() {
+        DefaultSettableGauge<Integer> settable = new DefaultSettableGauge<>(1);
+
+        assertThat(settable.getValue()).isEqualTo(1);
+    }
+
+    @Test
+    public void setValueAndThenGetValue() {
+        DefaultSettableGauge<String> settable = new DefaultSettableGauge<>("default");
+
+        settable.setValue("first");
+        assertThat(settable.getValue())
+                .isEqualTo("first");
+
+        settable.setValue("second");
+        assertThat(settable.getValue())
+                .isEqualTo("second");
+    }
+}
diff --git a/metrics-core/src/test/java/com/codahale/metrics/Slf4jReporterTest.java b/metrics-core/src/test/java/com/codahale/metrics/Slf4jReporterTest.java
index c0575f4..b17f58c 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/Slf4jReporterTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/Slf4jReporterTest.java
@@ -4,64 +4,114 @@ import org.junit.Test;
 import org.slf4j.Logger;
 import org.slf4j.Marker;
 
+import java.util.EnumSet;
+import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
 
-import static org.mockito.Mockito.*;
+import static com.codahale.metrics.MetricAttribute.COUNT;
+import static com.codahale.metrics.MetricAttribute.M1_RATE;
+import static com.codahale.metrics.MetricAttribute.MEAN_RATE;
+import static com.codahale.metrics.MetricAttribute.MIN;
+import static com.codahale.metrics.MetricAttribute.P50;
+import static com.codahale.metrics.MetricAttribute.P999;
+import static com.codahale.metrics.MetricAttribute.STDDEV;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 public class Slf4jReporterTest {
+
     private final Logger logger = mock(Logger.class);
     private final Marker marker = mock(Marker.class);
     private final MetricRegistry registry = mock(MetricRegistry.class);
-    private final Slf4jReporter infoReporter = Slf4jReporter.forRegistry(registry)
-            .outputTo(logger)
-            .markWith(marker)
-            .prefixedWith("prefix")
-            .convertRatesTo(TimeUnit.SECONDS)
-            .convertDurationsTo(TimeUnit.MILLISECONDS)
-            .withLoggingLevel(Slf4jReporter.LoggingLevel.INFO)
-            .filter(MetricFilter.ALL)
-            .build();
-
-    private final Slf4jReporter errorReporter = Slf4jReporter.forRegistry(registry)
-            .outputTo(logger)
-            .markWith(marker)
-            .convertRatesTo(TimeUnit.SECONDS)
-            .convertDurationsTo(TimeUnit.MILLISECONDS)
-            .withLoggingLevel(Slf4jReporter.LoggingLevel.ERROR)
-            .filter(MetricFilter.ALL)
-            .build();
 
-    @Test
-    public void reportsGaugeValuesAtError() throws Exception {
-        when(logger.isErrorEnabled(marker)).thenReturn(true);
-        errorReporter.report(map("gauge", gauge("value")),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map(),
-                this.<Timer>map());
+    /**
+     * The set of disabled metric attributes to pass to the Slf4jReporter builder
+     * in the default factory methods of {@link #infoReporter}
+     * and {@link #errorReporter}.
+     *
+     * This value can be overridden by tests before calling the {@link #infoReporter}
+     * and {@link #errorReporter} factory methods.
+     */
+    private Set<MetricAttribute> disabledMetricAttributes = null;
+
+    private Slf4jReporter infoReporter() {
+        return Slf4jReporter.forRegistry(registry)
+                .outputTo(logger)
+                .markWith(marker)
+                .prefixedWith("prefix")
+                .convertRatesTo(TimeUnit.SECONDS)
+                .convertDurationsTo(TimeUnit.MILLISECONDS)
+                .withLoggingLevel(Slf4jReporter.LoggingLevel.INFO)
+                .filter(MetricFilter.ALL)
+                .disabledMetricAttributes(disabledMetricAttributes)
+                .build();
+    }
 
-        verify(logger).error(marker, "type={}, name={}, value={}", new Object[]{"GAUGE", "gauge", "value"});
+    private Slf4jReporter errorReporter() {
+        return Slf4jReporter.forRegistry(registry)
+                .outputTo(logger)
+                .markWith(marker)
+                .convertRatesTo(TimeUnit.SECONDS)
+                .convertDurationsTo(TimeUnit.MILLISECONDS)
+                .withLoggingLevel(Slf4jReporter.LoggingLevel.ERROR)
+                .filter(MetricFilter.ALL)
+                .disabledMetricAttributes(disabledMetricAttributes)
+                .build();
     }
 
     @Test
-    public void reportsCounterValuesAtError() throws Exception {
-        final Counter counter = mock(Counter.class);
-        when(counter.getCount()).thenReturn(100L);
+    public void reportsGaugeValuesAtErrorDefault() {
+        reportsGaugeValuesAtError();
+    }
+
+    @Test
+    public void reportsGaugeValuesAtErrorAllDisabled() {
+        disabledMetricAttributes = EnumSet.allOf(MetricAttribute.class); // has no effect
+        reportsGaugeValuesAtError();
+    }
+
+    private void reportsGaugeValuesAtError() {
         when(logger.isErrorEnabled(marker)).thenReturn(true);
+        errorReporter().report(map("gauge", () -> "value"),
+                map(),
+                map(),
+                map(),
+                map());
 
-        errorReporter.report(this.<Gauge>map(),
-                map("test.counter", counter),
-                this.<Histogram>map(),
-                this.<Meter>map(),
-                this.<Timer>map());
+        verify(logger).error(marker, "type=GAUGE, name=gauge, value=value");
+    }
+
+
+    private Timer timer() {
+        final Timer timer = mock(Timer.class);
+        when(timer.getCount()).thenReturn(1L);
+
+        when(timer.getMeanRate()).thenReturn(2.0);
+        when(timer.getOneMinuteRate()).thenReturn(3.0);
+        when(timer.getFiveMinuteRate()).thenReturn(4.0);
+        when(timer.getFifteenMinuteRate()).thenReturn(5.0);
+
+        final Snapshot snapshot = mock(Snapshot.class);
+        when(snapshot.getMax()).thenReturn(TimeUnit.MILLISECONDS.toNanos(100));
+        when(snapshot.getMean()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(200));
+        when(snapshot.getMin()).thenReturn(TimeUnit.MILLISECONDS.toNanos(300));
+        when(snapshot.getStdDev()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(400));
+        when(snapshot.getMedian()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(500));
+        when(snapshot.get75thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(600));
+        when(snapshot.get95thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(700));
+        when(snapshot.get98thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(800));
+        when(snapshot.get99thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(900));
+        when(snapshot.get999thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS
+                .toNanos(1000));
 
-        verify(logger).error(marker, "type={}, name={}, count={}", new Object[]{"COUNTER", "test.counter", 100L});
+        when(timer.getSnapshot()).thenReturn(snapshot);
+        return timer;
     }
 
-    @Test
-    public void reportsHistogramValuesAtError() throws Exception {
+    private Histogram histogram() {
         final Histogram histogram = mock(Histogram.class);
         when(histogram.getCount()).thenReturn(1L);
 
@@ -78,282 +128,235 @@ public class Slf4jReporterTest {
         when(snapshot.get999thPercentile()).thenReturn(11.0);
 
         when(histogram.getSnapshot()).thenReturn(snapshot);
-        when(logger.isErrorEnabled(marker)).thenReturn(true);
-
-        errorReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                map("test.histogram", histogram),
-                this.<Meter>map(),
-                this.<Timer>map());
-
-        verify(logger).error(marker,
-                "type={}, name={}, count={}, min={}, max={}, mean={}, stddev={}, median={}, p75={}, p95={}, p98={}, p99={}, p999={}",
-                "HISTOGRAM",
-                "test.histogram",
-                1L,
-                4L,
-                2L,
-                3.0,
-                5.0,
-                6.0,
-                7.0,
-                8.0,
-                9.0,
-                10.0,
-                11.0);
+        return histogram;
     }
 
-    @Test
-    public void reportsMeterValuesAtError() throws Exception {
+    private Meter meter() {
         final Meter meter = mock(Meter.class);
         when(meter.getCount()).thenReturn(1L);
         when(meter.getMeanRate()).thenReturn(2.0);
         when(meter.getOneMinuteRate()).thenReturn(3.0);
         when(meter.getFiveMinuteRate()).thenReturn(4.0);
         when(meter.getFifteenMinuteRate()).thenReturn(5.0);
+        return meter;
+    }
+
+    private Counter counter() {
+        final Counter counter = mock(Counter.class);
+        when(counter.getCount()).thenReturn(100L);
+        return counter;
+    }
+
+    @Test
+    public void reportsCounterValuesAtErrorDefault() {
+        reportsCounterValuesAtError();
+    }
+
+    @Test
+    public void reportsCounterValuesAtErrorAllDisabled() {
+        disabledMetricAttributes = EnumSet.allOf(MetricAttribute.class); // has no effect
+        reportsCounterValuesAtError();
+    }
+
+    private void reportsCounterValuesAtError() {
+        final Counter counter = counter();
         when(logger.isErrorEnabled(marker)).thenReturn(true);
 
-        errorReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                map("test.meter", meter),
-                this.<Timer>map());
-
-        verify(logger).error(marker,
-                "type={}, name={}, count={}, mean_rate={}, m1={}, m5={}, m15={}, rate_unit={}",
-                "METER",
-                "test.meter",
-                1L,
-                2.0,
-                3.0,
-                4.0,
-                5.0,
-                "events/second");
+        errorReporter().report(map(),
+                map("test.counter", counter),
+                map(),
+                map(),
+                map());
+
+        verify(logger).error(marker, "type=COUNTER, name=test.counter, count=100");
     }
 
     @Test
-    public void reportsTimerValuesAtError() throws Exception {
-        final Timer timer = mock(Timer.class);
-        when(timer.getCount()).thenReturn(1L);
+    public void reportsHistogramValuesAtErrorDefault() {
+        reportsHistogramValuesAtError("type=HISTOGRAM, name=test.histogram, count=1, min=4, " +
+                "max=2, mean=3.0, stddev=5.0, p50=6.0, p75=7.0, p95=8.0, p98=9.0, p99=10.0, p999=11.0");
+    }
 
-        when(timer.getMeanRate()).thenReturn(2.0);
-        when(timer.getOneMinuteRate()).thenReturn(3.0);
-        when(timer.getFiveMinuteRate()).thenReturn(4.0);
-        when(timer.getFifteenMinuteRate()).thenReturn(5.0);
+    @Test
+    public void reportsHistogramValuesAtErrorWithDisabledMetricAttributes() {
+        disabledMetricAttributes = EnumSet.of(COUNT, MIN, P50);
+        reportsHistogramValuesAtError("type=HISTOGRAM, name=test.histogram, max=2, mean=3.0, " +
+                "stddev=5.0, p75=7.0, p95=8.0, p98=9.0, p99=10.0, p999=11.0");
+    }
 
-        final Snapshot snapshot = mock(Snapshot.class);
-        when(snapshot.getMax()).thenReturn(TimeUnit.MILLISECONDS.toNanos(100));
-        when(snapshot.getMean()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(200));
-        when(snapshot.getMin()).thenReturn(TimeUnit.MILLISECONDS.toNanos(300));
-        when(snapshot.getStdDev()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(400));
-        when(snapshot.getMedian()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(500));
-        when(snapshot.get75thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(600));
-        when(snapshot.get95thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(700));
-        when(snapshot.get98thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(800));
-        when(snapshot.get99thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(900));
-        when(snapshot.get999thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS
-                .toNanos(1000));
+    private void reportsHistogramValuesAtError(final String expectedLog) {
+        final Histogram histogram = histogram();
+        when(logger.isErrorEnabled(marker)).thenReturn(true);
 
-        when(timer.getSnapshot()).thenReturn(snapshot);
+        errorReporter().report(map(),
+                map(),
+                map("test.histogram", histogram),
+                map(),
+                map());
+
+        verify(logger).error(marker, expectedLog);
+    }
+
+    @Test
+    public void reportsMeterValuesAtErrorDefault() {
+        reportsMeterValuesAtError("type=METER, name=test.meter, count=1, m1_rate=3.0, m5_rate=4.0, " +
+                "m15_rate=5.0, mean_rate=2.0, rate_unit=events/second");
+    }
 
+    @Test
+    public void reportsMeterValuesAtErrorWithDisabledMetricAttributes() {
+        disabledMetricAttributes = EnumSet.of(MIN, P50, M1_RATE);
+        reportsMeterValuesAtError("type=METER, name=test.meter, count=1, m5_rate=4.0, m15_rate=5.0, " +
+                "mean_rate=2.0, rate_unit=events/second");
+    }
+
+    private void reportsMeterValuesAtError(final String expectedLog) {
+        final Meter meter = meter();
         when(logger.isErrorEnabled(marker)).thenReturn(true);
 
-        errorReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map(),
+        errorReporter().report(map(),
+                map(),
+                map(),
+                map("test.meter", meter),
+                map());
+
+        verify(logger).error(marker, expectedLog);
+    }
+
+
+    @Test
+    public void reportsTimerValuesAtErrorDefault() {
+        reportsTimerValuesAtError("type=TIMER, name=test.another.timer, count=1, min=300.0, max=100.0, " +
+                "mean=200.0, stddev=400.0, p50=500.0, p75=600.0, p95=700.0, p98=800.0, p99=900.0, p999=1000.0, " +
+                "m1_rate=3.0, m5_rate=4.0, m15_rate=5.0, mean_rate=2.0, rate_unit=events/second, " +
+                "duration_unit=milliseconds");
+    }
+
+    @Test
+    public void reportsTimerValuesAtErrorWithDisabledMetricAttributes() {
+        disabledMetricAttributes = EnumSet.of(MIN, STDDEV, P999, MEAN_RATE);
+        reportsTimerValuesAtError("type=TIMER, name=test.another.timer, count=1, max=100.0, mean=200.0, " +
+                "p50=500.0, p75=600.0, p95=700.0, p98=800.0, p99=900.0, m1_rate=3.0, m5_rate=4.0, m15_rate=5.0, " +
+                "rate_unit=events/second, duration_unit=milliseconds");
+    }
+
+    private void reportsTimerValuesAtError(final String expectedLog) {
+        final Timer timer = timer();
+
+        when(logger.isErrorEnabled(marker)).thenReturn(true);
+
+        errorReporter().report(map(),
+                map(),
+                map(),
+                map(),
                 map("test.another.timer", timer));
 
-        verify(logger).error(marker,
-                "type={}, name={}, count={}, min={}, max={}, mean={}, stddev={}, median={}, p75={}, p95={}, p98={}, p99={}, p999={}, mean_rate={}, m1={}, m5={}, m15={}, rate_unit={}, duration_unit={}",
-                "TIMER",
-                "test.another.timer",
-                1L,
-                300.0,
-                100.0,
-                200.0,
-                400.0,
-                500.0,
-                600.0,
-                700.0,
-                800.0,
-                900.0,
-                1000.0,
-                2.0,
-                3.0,
-                4.0,
-                5.0,
-                "events/second",
-                "milliseconds");
+        verify(logger).error(marker, expectedLog);
     }
 
     @Test
-    public void reportsGaugeValues() throws Exception {
+    public void reportsGaugeValuesDefault() {
         when(logger.isInfoEnabled(marker)).thenReturn(true);
-        infoReporter.report(map("gauge", gauge("value")),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map(),
-                this.<Timer>map());
+        infoReporter().report(map("gauge", () -> "value"),
+                map(),
+                map(),
+                map(),
+                map());
 
-        verify(logger).info(marker, "type={}, name={}, value={}", new Object[]{"GAUGE", "prefix.gauge", "value"});
+        verify(logger).info(marker, "type=GAUGE, name=prefix.gauge, value=value");
     }
 
+
     @Test
-    public void reportsCounterValues() throws Exception {
-        final Counter counter = mock(Counter.class);
-        when(counter.getCount()).thenReturn(100L);
+    public void reportsCounterValuesDefault() {
+        final Counter counter = counter();
         when(logger.isInfoEnabled(marker)).thenReturn(true);
 
-        infoReporter.report(this.<Gauge>map(),
+        infoReporter().report(map(),
                 map("test.counter", counter),
-                this.<Histogram>map(),
-                this.<Meter>map(),
-                this.<Timer>map());
+                map(),
+                map(),
+                map());
 
-        verify(logger).info(marker, "type={}, name={}, count={}", new Object[]{"COUNTER", "prefix.test.counter", 100L});
+        verify(logger).info(marker, "type=COUNTER, name=prefix.test.counter, count=100");
     }
 
     @Test
-    public void reportsHistogramValues() throws Exception {
-        final Histogram histogram = mock(Histogram.class);
-        when(histogram.getCount()).thenReturn(1L);
-
-        final Snapshot snapshot = mock(Snapshot.class);
-        when(snapshot.getMax()).thenReturn(2L);
-        when(snapshot.getMean()).thenReturn(3.0);
-        when(snapshot.getMin()).thenReturn(4L);
-        when(snapshot.getStdDev()).thenReturn(5.0);
-        when(snapshot.getMedian()).thenReturn(6.0);
-        when(snapshot.get75thPercentile()).thenReturn(7.0);
-        when(snapshot.get95thPercentile()).thenReturn(8.0);
-        when(snapshot.get98thPercentile()).thenReturn(9.0);
-        when(snapshot.get99thPercentile()).thenReturn(10.0);
-        when(snapshot.get999thPercentile()).thenReturn(11.0);
-
-        when(histogram.getSnapshot()).thenReturn(snapshot);
+    public void reportsHistogramValuesDefault() {
+        final Histogram histogram = histogram();
         when(logger.isInfoEnabled(marker)).thenReturn(true);
 
-        infoReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
+        infoReporter().report(map(),
+                map(),
                 map("test.histogram", histogram),
-                this.<Meter>map(),
-                this.<Timer>map());
-
-        verify(logger).info(marker,
-                "type={}, name={}, count={}, min={}, max={}, mean={}, stddev={}, median={}, p75={}, p95={}, p98={}, p99={}, p999={}",
-                "HISTOGRAM",
-                "prefix.test.histogram",
-                1L,
-                4L,
-                2L,
-                3.0,
-                5.0,
-                6.0,
-                7.0,
-                8.0,
-                9.0,
-                10.0,
-                11.0);
+                map(),
+                map());
+
+        verify(logger).info(marker, "type=HISTOGRAM, name=prefix.test.histogram, count=1, min=4, max=2, mean=3.0, " +
+                "stddev=5.0, p50=6.0, p75=7.0, p95=8.0, p98=9.0, p99=10.0, p999=11.0");
     }
 
     @Test
-    public void reportsMeterValues() throws Exception {
-        final Meter meter = mock(Meter.class);
-        when(meter.getCount()).thenReturn(1L);
-        when(meter.getMeanRate()).thenReturn(2.0);
-        when(meter.getOneMinuteRate()).thenReturn(3.0);
-        when(meter.getFiveMinuteRate()).thenReturn(4.0);
-        when(meter.getFifteenMinuteRate()).thenReturn(5.0);
+    public void reportsMeterValuesDefault() {
+        final Meter meter = meter();
         when(logger.isInfoEnabled(marker)).thenReturn(true);
 
-        infoReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                this.<Histogram>map(),
+        infoReporter().report(map(),
+                map(),
+                map(),
                 map("test.meter", meter),
-                this.<Timer>map());
-
-        verify(logger).info(marker,
-                "type={}, name={}, count={}, mean_rate={}, m1={}, m5={}, m15={}, rate_unit={}",
-                "METER",
-                "prefix.test.meter",
-                1L,
-                2.0,
-                3.0,
-                4.0,
-                5.0,
-                "events/second");
+                map());
+
+        verify(logger).info(marker, "type=METER, name=prefix.test.meter, count=1, m1_rate=3.0, m5_rate=4.0, " +
+                "m15_rate=5.0, mean_rate=2.0, rate_unit=events/second");
     }
 
     @Test
-    public void reportsTimerValues() throws Exception {
-        final Timer timer = mock(Timer.class);
-        when(timer.getCount()).thenReturn(1L);
+    public void reportsTimerValuesDefault() {
+        final Timer timer = timer();
+        when(logger.isInfoEnabled(marker)).thenReturn(true);
 
-        when(timer.getMeanRate()).thenReturn(2.0);
-        when(timer.getOneMinuteRate()).thenReturn(3.0);
-        when(timer.getFiveMinuteRate()).thenReturn(4.0);
-        when(timer.getFifteenMinuteRate()).thenReturn(5.0);
+        infoReporter().report(map(),
+                map(),
+                map(),
+                map(),
+                map("test.another.timer", timer));
 
-        final Snapshot snapshot = mock(Snapshot.class);
-        when(snapshot.getMax()).thenReturn(TimeUnit.MILLISECONDS.toNanos(100));
-        when(snapshot.getMean()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(200));
-        when(snapshot.getMin()).thenReturn(TimeUnit.MILLISECONDS.toNanos(300));
-        when(snapshot.getStdDev()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(400));
-        when(snapshot.getMedian()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(500));
-        when(snapshot.get75thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(600));
-        when(snapshot.get95thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(700));
-        when(snapshot.get98thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(800));
-        when(snapshot.get99thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(900));
-        when(snapshot.get999thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS
-                .toNanos(1000));
+        verify(logger).info(marker, "type=TIMER, name=prefix.test.another.timer, count=1, min=300.0, max=100.0, " +
+                "mean=200.0, stddev=400.0, p50=500.0, p75=600.0, p95=700.0, p98=800.0, p99=900.0, p999=1000.0," +
+                " m1_rate=3.0, m5_rate=4.0, m15_rate=5.0, mean_rate=2.0, rate_unit=events/second, duration_unit=milliseconds");
+    }
 
-        when(timer.getSnapshot()).thenReturn(snapshot);
-        when(logger.isInfoEnabled(marker)).thenReturn(true);
 
-        infoReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map(),
-                map("test.another.timer", timer));
+    @Test
+    public void reportsAllMetricsDefault() {
+        when(logger.isInfoEnabled(marker)).thenReturn(true);
 
-        verify(logger).info(marker,
-                "type={}, name={}, count={}, min={}, max={}, mean={}, stddev={}, median={}, p75={}, p95={}, p98={}, p99={}, p999={}, mean_rate={}, m1={}, m5={}, m15={}, rate_unit={}, duration_unit={}",
-                "TIMER",
-                "prefix.test.another.timer",
-                1L,
-                300.0,
-                100.0,
-                200.0,
-                400.0,
-                500.0,
-                600.0,
-                700.0,
-                800.0,
-                900.0,
-                1000.0,
-                2.0,
-                3.0,
-                4.0,
-                5.0,
-                "events/second",
-                "milliseconds");
+        infoReporter().report(map("test.gauge", () -> "value"),
+                map("test.counter", counter()),
+                map("test.histogram", histogram()),
+                map("test.meter", meter()),
+                map("test.timer", timer()));
+
+        verify(logger).info(marker, "type=GAUGE, name=prefix.test.gauge, value=value");
+        verify(logger).info(marker, "type=COUNTER, name=prefix.test.counter, count=100");
+        verify(logger).info(marker, "type=HISTOGRAM, name=prefix.test.histogram, count=1, min=4, max=2, mean=3.0, " +
+                "stddev=5.0, p50=6.0, p75=7.0, p95=8.0, p98=9.0, p99=10.0, p999=11.0");
+        verify(logger).info(marker, "type=METER, name=prefix.test.meter, count=1, m1_rate=3.0, m5_rate=4.0, " +
+                "m15_rate=5.0, mean_rate=2.0, rate_unit=events/second");
+        verify(logger).info(marker, "type=TIMER, name=prefix.test.timer, count=1, min=300.0, max=100.0, " +
+                "mean=200.0, stddev=400.0, p50=500.0, p75=600.0, p95=700.0, p98=800.0, p99=900.0, p999=1000.0," +
+                " m1_rate=3.0, m5_rate=4.0, m15_rate=5.0, mean_rate=2.0, rate_unit=events/second, duration_unit=milliseconds");
     }
 
     private <T> SortedMap<String, T> map() {
-        return new TreeMap<String, T>();
+        return new TreeMap<>();
     }
 
     private <T> SortedMap<String, T> map(String name, T metric) {
-        final TreeMap<String, T> map = new TreeMap<String, T>();
+        final TreeMap<String, T> map = new TreeMap<>();
         map.put(name, metric);
         return map;
     }
 
-    private <T> Gauge gauge(T value) {
-        final Gauge gauge = mock(Gauge.class);
-        when(gauge.getValue()).thenReturn(value);
-        return gauge;
-    }
-
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTest.java b/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTest.java
index 9636823..a9dec13 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTest.java
@@ -9,14 +9,14 @@ import org.junit.Test;
 
 import java.util.Arrays;
 import java.util.Random;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.atomic.AtomicLong;
 
 @SuppressWarnings("Duplicates")
 public class SlidingTimeWindowArrayReservoirTest {
 
     @Test
-    public void storesMeasurementsWithDuplicateTicks() throws Exception {
+    public void storesMeasurementsWithDuplicateTicks() {
         final Clock clock = mock(Clock.class);
         final SlidingTimeWindowArrayReservoir reservoir = new SlidingTimeWindowArrayReservoir(10, NANOSECONDS, clock);
 
@@ -26,11 +26,11 @@ public class SlidingTimeWindowArrayReservoirTest {
         reservoir.update(2);
 
         assertThat(reservoir.getSnapshot().getValues())
-            .containsOnly(1, 2);
+                .containsOnly(1, 2);
     }
 
     @Test
-    public void boundsMeasurementsToATimeWindow() throws Exception {
+    public void boundsMeasurementsToATimeWindow() {
         final Clock clock = mock(Clock.class);
         final SlidingTimeWindowArrayReservoir reservoir = new SlidingTimeWindowArrayReservoir(10, NANOSECONDS, clock);
 
@@ -50,7 +50,7 @@ public class SlidingTimeWindowArrayReservoirTest {
         reservoir.update(5);
 
         assertThat(reservoir.getSnapshot().getValues())
-            .containsOnly(4, 5);
+                .containsOnly(4, 5);
     }
 
     @Test
@@ -111,8 +111,8 @@ public class SlidingTimeWindowArrayReservoirTest {
                     // Randomly check the reservoir size
                     if (random.nextDouble() < 0.1) {
                         assertThat(reservoir.size())
-                            .as("Bad reservoir size with: threshold=%d, updatesPerTick=%d", threshold, updatesPerTick)
-                            .isLessThanOrEqualTo(window * 256);
+                                .as("Bad reservoir size with: threshold=%d, updatesPerTick=%d", threshold, updatesPerTick)
+                                .isLessThanOrEqualTo(window * 256);
                     }
 
                     // Update the clock
@@ -128,8 +128,8 @@ public class SlidingTimeWindowArrayReservoirTest {
 
                 // Check the final reservoir size
                 assertThat(reservoir.size())
-                    .as("Bad final reservoir size with: threshold=%d, updatesPerTick=%d", threshold, updatesPerTick)
-                    .isLessThanOrEqualTo(window * 256);
+                        .as("Bad final reservoir size with: threshold=%d, updatesPerTick=%d", threshold, updatesPerTick)
+                        .isLessThanOrEqualTo(window * 256);
 
                 // Advance the clock far enough to clear the reservoir.  Note that here the window only loosely defines
                 // the reservoir window; when updatesPerTick is greater than 128 the sliding window will always be well
@@ -142,8 +142,8 @@ public class SlidingTimeWindowArrayReservoirTest {
 
                 // The reservoir should now be empty
                 assertThat(reservoir.size())
-                    .as("Bad reservoir size after delay with: threshold=%d, updatesPerTick=%d", threshold, updatesPerTick)
-                    .isEqualTo(0);
+                        .as("Bad reservoir size after delay with: threshold=%d, updatesPerTick=%d", threshold, updatesPerTick)
+                        .isEqualTo(0);
             }
         }
     }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowMovingAveragesTest.java b/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowMovingAveragesTest.java
new file mode 100644
index 0000000..878b36f
--- /dev/null
+++ b/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowMovingAveragesTest.java
@@ -0,0 +1,166 @@
+package com.codahale.metrics;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Instant;
+
+import static com.codahale.metrics.SlidingTimeWindowMovingAverages.NUMBER_OF_BUCKETS;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class SlidingTimeWindowMovingAveragesTest {
+
+    private ManualClock clock;
+    private SlidingTimeWindowMovingAverages movingAverages;
+    private Meter meter;
+
+    @Before
+    public void init() {
+        clock = new ManualClock();
+        movingAverages = new SlidingTimeWindowMovingAverages(clock);
+        meter = new Meter(movingAverages, clock);
+    }
+
+    @Test
+    public void normalizeIndex() {
+
+        SlidingTimeWindowMovingAverages stwm = new SlidingTimeWindowMovingAverages();
+
+        assertThat(stwm.normalizeIndex(0)).isEqualTo(0);
+        assertThat(stwm.normalizeIndex(900)).isEqualTo(0);
+        assertThat(stwm.normalizeIndex(9000)).isEqualTo(0);
+        assertThat(stwm.normalizeIndex(-900)).isEqualTo(0);
+
+        assertThat(stwm.normalizeIndex(1)).isEqualTo(1);
+
+        assertThat(stwm.normalizeIndex(899)).isEqualTo(899);
+        assertThat(stwm.normalizeIndex(-1)).isEqualTo(899);
+        assertThat(stwm.normalizeIndex(-901)).isEqualTo(899);
+    }
+
+    @Test
+    public void calculateIndexOfTick() {
+
+        SlidingTimeWindowMovingAverages stwm = new SlidingTimeWindowMovingAverages(clock);
+
+        assertThat(stwm.calculateIndexOfTick(Instant.ofEpochSecond(0L))).isEqualTo(0);
+        assertThat(stwm.calculateIndexOfTick(Instant.ofEpochSecond(1L))).isEqualTo(1);
+    }
+
+    @Test
+    public void mark_max_without_cleanup() {
+
+        int markCount = NUMBER_OF_BUCKETS;
+
+        // compensate the first addSeconds in the loop; first tick should be at zero
+        clock.addSeconds(-1);
+
+        for (int i = 0; i < markCount; i++) {
+            clock.addSeconds(1);
+            meter.mark();
+        }
+
+        // verify that no cleanup happened yet
+        assertThat(movingAverages.oldestBucketTime).isEqualTo(Instant.ofEpochSecond(0L));
+
+        assertThat(meter.getOneMinuteRate()).isEqualTo(60.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(300.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(900.0);
+    }
+
+    @Test
+    public void mark_first_cleanup() {
+
+        int markCount = NUMBER_OF_BUCKETS + 1;
+
+        // compensate the first addSeconds in the loop; first tick should be at zero
+        clock.addSeconds(-1);
+
+        for (int i = 0; i < markCount; i++) {
+            clock.addSeconds(1);
+            meter.mark();
+        }
+
+        // verify that at least one cleanup happened
+        assertThat(movingAverages.oldestBucketTime).isNotEqualTo(Instant.EPOCH);
+
+        assertThat(meter.getOneMinuteRate()).isEqualTo(60.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(300.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(900.0);
+    }
+
+    @Test
+    public void mark_10_values() {
+
+        // compensate the first addSeconds in the loop; first tick should be at zero
+        clock.addSeconds(-1);
+
+        for (int i = 0; i < 10; i++) {
+            clock.addSeconds(1);
+            meter.mark();
+        }
+
+        assertThat(meter.getCount()).isEqualTo(10L);
+        assertThat(meter.getOneMinuteRate()).isEqualTo(10.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(10.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(10.0);
+    }
+
+    @Test
+    public void mark_1000_values() {
+
+        for (int i = 0; i < 1000; i++) {
+            clock.addSeconds(1);
+            meter.mark();
+        }
+
+        // only 60/300/900 of the 1000 events took place in the last 1/5/15 minute(s)
+        assertThat(meter.getOneMinuteRate()).isEqualTo(60.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(300.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(900.0);
+    }
+
+    @Test
+    public void cleanup_pause_shorter_than_window() {
+
+        meter.mark(10);
+
+        // no mark for three minutes
+        clock.addSeconds(180);
+        assertThat(meter.getOneMinuteRate()).isEqualTo(0.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(10.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(10.0);
+    }
+
+    @Test
+    public void cleanup_window_wrap_around() {
+
+        // mark at 14:40 minutes of the 15 minute window...
+        clock.addSeconds(880);
+        meter.mark(10);
+
+        // and query at 15:30 minutes (the bucket index must have wrapped around)
+        clock.addSeconds(50);
+        assertThat(meter.getOneMinuteRate()).isEqualTo(10.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(10.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(10.0);
+
+        // and query at 30:10 minutes (the bucket index must have wrapped around for the second time)
+        clock.addSeconds(880);
+        assertThat(meter.getOneMinuteRate()).isEqualTo(0.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(0.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(0.0);
+    }
+
+    @Test
+    public void cleanup_pause_longer_than_two_windows() {
+
+        meter.mark(10);
+
+        // after forty minutes all rates should be zero
+        clock.addSeconds(2400);
+        assertThat(meter.getOneMinuteRate()).isEqualTo(0.0);
+        assertThat(meter.getFiveMinuteRate()).isEqualTo(0.0);
+        assertThat(meter.getFifteenMinuteRate()).isEqualTo(0.0);
+    }
+}
\ No newline at end of file
diff --git a/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowReservoirTest.java b/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowReservoirTest.java
index d4a0a37..9b8458c 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowReservoirTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/SlidingTimeWindowReservoirTest.java
@@ -4,7 +4,6 @@ import org.junit.Test;
 
 import java.util.Arrays;
 import java.util.Random;
-import java.util.concurrent.TimeUnit;
 
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -13,7 +12,7 @@ import static org.mockito.Mockito.when;
 
 public class SlidingTimeWindowReservoirTest {
     @Test
-    public void storesMeasurementsWithDuplicateTicks() throws Exception {
+    public void storesMeasurementsWithDuplicateTicks() {
         final Clock clock = mock(Clock.class);
         final SlidingTimeWindowReservoir reservoir = new SlidingTimeWindowReservoir(10, NANOSECONDS, clock);
 
@@ -27,8 +26,10 @@ public class SlidingTimeWindowReservoirTest {
     }
 
     @Test
-    public void boundsMeasurementsToATimeWindow() throws Exception {
+    public void boundsMeasurementsToATimeWindow() {
         final Clock clock = mock(Clock.class);
+        when(clock.getTick()).thenReturn(0L);
+
         final SlidingTimeWindowReservoir reservoir = new SlidingTimeWindowReservoir(10, NANOSECONDS, clock);
 
         when(clock.getTick()).thenReturn(0L);
@@ -51,7 +52,7 @@ public class SlidingTimeWindowReservoirTest {
     }
 
     @Test
-    public void testGetTickOverflow () {
+    public void testGetTickOverflow() {
         final Random random = new Random(0);
         final int window = 128;
 
@@ -62,14 +63,15 @@ public class SlidingTimeWindowReservoirTest {
             for (int updatesPerTick : Arrays.asList(1, 2, 127, 128, 129, 255, 256, 257)) {
                 //logger.info("Executing test: threshold={}, updatesPerTick={}", threshold, updatesPerTick);
 
-                // Set the clock to overflow in (2*window+1)ns
                 final ManualClock clock = new ManualClock();
-                clock.addNanos(Long.MAX_VALUE/256 - 2*window - clock.getTick());
-                assertThat(clock.getTick() * 256).isGreaterThan(0);
 
                 // Create the reservoir
                 final SlidingTimeWindowReservoir reservoir = new SlidingTimeWindowReservoir(window, NANOSECONDS, clock);
 
+                // Set the clock to overflow in (2*window+1)ns
+                clock.addNanos(Long.MAX_VALUE / 256 - 2 * window - clock.getTick());
+                assertThat(clock.getTick() * 256).isGreaterThan(0);
+
                 int updatesAfterThreshold = 0;
                 while (true) {
                     // Update the reservoir
diff --git a/metrics-core/src/test/java/com/codahale/metrics/SlidingWindowReservoirTest.java b/metrics-core/src/test/java/com/codahale/metrics/SlidingWindowReservoirTest.java
index d8d827c..322431e 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/SlidingWindowReservoirTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/SlidingWindowReservoirTest.java
@@ -8,7 +8,7 @@ public class SlidingWindowReservoirTest {
     private final SlidingWindowReservoir reservoir = new SlidingWindowReservoir(3);
 
     @Test
-    public void handlesSmallDataStreams() throws Exception {
+    public void handlesSmallDataStreams() {
         reservoir.update(1);
         reservoir.update(2);
 
@@ -17,7 +17,7 @@ public class SlidingWindowReservoirTest {
     }
 
     @Test
-    public void onlyKeepsTheMostRecentFromBigDataStreams() throws Exception {
+    public void onlyKeepsTheMostRecentFromBigDataStreams() {
         reservoir.update(1);
         reservoir.update(2);
         reservoir.update(3);
diff --git a/metrics-core/src/test/java/com/codahale/metrics/TimerTest.java b/metrics-core/src/test/java/com/codahale/metrics/TimerTest.java
index c321cf6..94e14c5 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/TimerTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/TimerTest.java
@@ -2,13 +2,16 @@ package com.codahale.metrics;
 
 import org.junit.Test;
 
-import java.util.concurrent.Callable;
+import java.time.Duration;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.offset;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
 
 public class TimerTest {
     private final Reservoir reservoir = mock(Reservoir.class);
@@ -24,7 +27,7 @@ public class TimerTest {
     private final Timer timer = new Timer(reservoir, clock);
 
     @Test
-    public void hasRates() throws Exception {
+    public void hasRates() {
         assertThat(timer.getCount())
                 .isZero();
 
@@ -42,7 +45,7 @@ public class TimerTest {
     }
 
     @Test
-    public void updatesTheCountOnUpdates() throws Exception {
+    public void updatesTheCountOnUpdates() {
         assertThat(timer.getCount())
                 .isZero();
 
@@ -54,12 +57,7 @@ public class TimerTest {
 
     @Test
     public void timesCallableInstances() throws Exception {
-        final String value = timer.time(new Callable<String>() {
-            @Override
-            public String call() throws Exception {
-                return "one";
-            }
-        });
+        final String value = timer.time(() -> "one");
 
         assertThat(timer.getCount())
                 .isEqualTo(1);
@@ -71,14 +69,22 @@ public class TimerTest {
     }
 
     @Test
-    public void timesRunnableInstances() throws Exception {
+    public void timesSuppliedInstances() {
+        final String value = timer.timeSupplier(() -> "one");
+
+        assertThat(timer.getCount())
+                .isEqualTo(1);
+
+        assertThat(value)
+                .isEqualTo("one");
+
+        verify(reservoir).update(50000000);
+    }
+
+    @Test
+    public void timesRunnableInstances() {
         final AtomicBoolean called = new AtomicBoolean();
-        timer.time(new Runnable() {
-            @Override
-            public void run() {
-                called.set(true);
-            }
-        });
+        timer.time(() -> called.set(true));
 
         assertThat(timer.getCount())
                 .isEqualTo(1);
@@ -90,7 +96,7 @@ public class TimerTest {
     }
 
     @Test
-    public void timesContexts() throws Exception {
+    public void timesContexts() {
         timer.time().stop();
 
         assertThat(timer.getCount())
@@ -100,7 +106,7 @@ public class TimerTest {
     }
 
     @Test
-    public void returnsTheSnapshotFromTheReservoir() throws Exception {
+    public void returnsTheSnapshotFromTheReservoir() {
         final Snapshot snapshot = mock(Snapshot.class);
         when(reservoir.getSnapshot()).thenReturn(snapshot);
 
@@ -109,12 +115,47 @@ public class TimerTest {
     }
 
     @Test
-    public void ignoresNegativeValues() throws Exception {
+    public void ignoresNegativeValues() {
         timer.update(-1, TimeUnit.SECONDS);
 
         assertThat(timer.getCount())
                 .isZero();
 
-        verifyZeroInteractions(reservoir);
+        verifyNoInteractions(reservoir);
+    }
+
+    @Test
+    public void java8Duration() {
+        timer.update(Duration.ofSeconds(1234));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+
+        verify(reservoir).update((long) 1234e9);
     }
+
+    @Test
+    public void java8NegativeDuration() {
+        timer.update(Duration.ofMillis(-5678));
+
+        assertThat(timer.getCount()).isZero();
+
+        verifyNoInteractions(reservoir);
+    }
+
+    @Test
+    public void tryWithResourcesWork() {
+        assertThat(timer.getCount()).isZero();
+
+        int dummy = 0;
+        try (Timer.Context context = timer.time()) {
+            assertThat(context).isNotNull();
+            dummy += 1;
+        }
+        assertThat(dummy).isEqualTo(1);
+        assertThat(timer.getCount())
+                .isEqualTo(1);
+
+        verify(reservoir).update(50000000);
+    }
+
 }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/UniformReservoirTest.java b/metrics-core/src/test/java/com/codahale/metrics/UniformReservoirTest.java
index 2234578..6c90808 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/UniformReservoirTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/UniformReservoirTest.java
@@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 public class UniformReservoirTest {
     @Test
     @SuppressWarnings("unchecked")
-    public void aReservoirOf100OutOf1000Elements() throws Exception {
+    public void aReservoirOf100OutOf1000Elements() {
         final UniformReservoir reservoir = new UniformReservoir(100);
         for (int i = 0; i < 1000; i++) {
             reservoir.update(i);
diff --git a/metrics-core/src/test/java/com/codahale/metrics/UniformSnapshotTest.java b/metrics-core/src/test/java/com/codahale/metrics/UniformSnapshotTest.java
index 46dcc88..d1ed091 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/UniformSnapshotTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/UniformSnapshotTest.java
@@ -11,83 +11,81 @@ import java.util.concurrent.TimeUnit;
 import static java.util.Arrays.asList;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.offset;
-import static org.mockito.Mockito.spy;
-import static org.mockito.Mockito.when;
 
 public class UniformSnapshotTest {
     private final Snapshot snapshot = new UniformSnapshot(new long[]{5, 1, 2, 3, 4});
 
     @Test
-    public void smallQuantilesAreTheFirstValue() throws Exception {
+    public void smallQuantilesAreTheFirstValue() {
         assertThat(snapshot.getValue(0.0))
                 .isEqualTo(1, offset(0.1));
     }
 
     @Test
-    public void bigQuantilesAreTheLastValue() throws Exception {
+    public void bigQuantilesAreTheLastValue() {
         assertThat(snapshot.getValue(1.0))
                 .isEqualTo(5, offset(0.1));
     }
 
     @Test(expected = IllegalArgumentException.class)
     public void disallowsNotANumberQuantile() {
-        snapshot.getValue( Double.NaN );
+        snapshot.getValue(Double.NaN);
     }
 
     @Test(expected = IllegalArgumentException.class)
     public void disallowsNegativeQuantile() {
-        snapshot.getValue( -0.5 );
+        snapshot.getValue(-0.5);
     }
 
     @Test(expected = IllegalArgumentException.class)
     public void disallowsQuantileOverOne() {
-       snapshot.getValue( 1.5 );
+        snapshot.getValue(1.5);
     }
 
     @Test
-    public void hasAMedian() throws Exception {
+    public void hasAMedian() {
         assertThat(snapshot.getMedian()).isEqualTo(3, offset(0.1));
     }
 
     @Test
-    public void hasAp75() throws Exception {
+    public void hasAp75() {
         assertThat(snapshot.get75thPercentile()).isEqualTo(4.5, offset(0.1));
     }
 
     @Test
-    public void hasAp95() throws Exception {
+    public void hasAp95() {
         assertThat(snapshot.get95thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasAp98() throws Exception {
+    public void hasAp98() {
         assertThat(snapshot.get98thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasAp99() throws Exception {
+    public void hasAp99() {
         assertThat(snapshot.get99thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasAp999() throws Exception {
+    public void hasAp999() {
         assertThat(snapshot.get999thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasValues() throws Exception {
+    public void hasValues() {
         assertThat(snapshot.getValues())
                 .containsOnly(1, 2, 3, 4, 5);
     }
 
     @Test
-    public void hasASize() throws Exception {
+    public void hasASize() {
         assertThat(snapshot.size())
                 .isEqualTo(5);
     }
 
     @Test
-    public void canAlsoBeCreatedFromACollectionOfLongs() throws Exception {
+    public void canAlsoBeCreatedFromACollectionOfLongs() {
         final Snapshot other = new UniformSnapshot(asList(5L, 1L, 2L, 3L, 4L));
 
         assertThat(other.getValues())
@@ -96,21 +94,18 @@ public class UniformSnapshotTest {
 
     @Test
     public void correctlyCreatedFromCollectionWithWeakIterator() throws Exception {
-        final ConcurrentSkipListSet<Long> values = new ConcurrentSkipListSet<Long>();
+        final ConcurrentSkipListSet<Long> values = new ConcurrentSkipListSet<>();
 
         // Create a latch to make sure that the background thread has started and
         // pushed some data to the collection.
         final CountDownLatch latch = new CountDownLatch(10);
-        final Thread backgroundThread = new Thread(new Runnable() {
-            @Override
-            public void run() {
-                final Random random = new Random();
-                // Update the collection in the loop to trigger a potential `ArrayOutOfBoundException`
-                // and verify that the snapshot doesn't make assumptions about the size of the iterator.
-                while (!Thread.currentThread().isInterrupted()) {
-                    values.add(random.nextLong());
-                    latch.countDown();
-                }
+        final Thread backgroundThread = new Thread(() -> {
+            final Random random = new Random();
+            // Update the collection in the loop to trigger a potential `ArrayOutOfBoundException`
+            // and verify that the snapshot doesn't make assumptions about the size of the iterator.
+            while (!Thread.currentThread().isInterrupted()) {
+                values.add(random.nextLong());
+                latch.countDown();
             }
         });
         backgroundThread.start();
@@ -128,7 +123,7 @@ public class UniformSnapshotTest {
     }
 
     @Test
-    public void dumpsToAStream() throws Exception {
+    public void dumpsToAStream() {
         final ByteArrayOutputStream output = new ByteArrayOutputStream();
 
         snapshot.dump(output);
@@ -138,64 +133,64 @@ public class UniformSnapshotTest {
     }
 
     @Test
-    public void calculatesTheMinimumValue() throws Exception {
+    public void calculatesTheMinimumValue() {
         assertThat(snapshot.getMin())
                 .isEqualTo(1);
     }
 
     @Test
-    public void calculatesTheMaximumValue() throws Exception {
+    public void calculatesTheMaximumValue() {
         assertThat(snapshot.getMax())
                 .isEqualTo(5);
     }
 
     @Test
-    public void calculatesTheMeanValue() throws Exception {
+    public void calculatesTheMeanValue() {
         assertThat(snapshot.getMean())
                 .isEqualTo(3.0);
     }
 
     @Test
-    public void calculatesTheStdDev() throws Exception {
+    public void calculatesTheStdDev() {
         assertThat(snapshot.getStdDev())
                 .isEqualTo(1.5811, offset(0.0001));
     }
 
     @Test
-    public void calculatesAMinOfZeroForAnEmptySnapshot() throws Exception {
-        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{ });
+    public void calculatesAMinOfZeroForAnEmptySnapshot() {
+        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{});
 
         assertThat(emptySnapshot.getMin())
                 .isZero();
     }
 
     @Test
-    public void calculatesAMaxOfZeroForAnEmptySnapshot() throws Exception {
-        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{ });
+    public void calculatesAMaxOfZeroForAnEmptySnapshot() {
+        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{});
 
         assertThat(emptySnapshot.getMax())
                 .isZero();
     }
 
     @Test
-    public void calculatesAMeanOfZeroForAnEmptySnapshot() throws Exception {
-        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{ });
+    public void calculatesAMeanOfZeroForAnEmptySnapshot() {
+        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{});
 
         assertThat(emptySnapshot.getMean())
                 .isZero();
     }
 
     @Test
-    public void calculatesAStdDevOfZeroForAnEmptySnapshot() throws Exception {
-        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{ });
+    public void calculatesAStdDevOfZeroForAnEmptySnapshot() {
+        final Snapshot emptySnapshot = new UniformSnapshot(new long[]{});
 
         assertThat(emptySnapshot.getStdDev())
                 .isZero();
     }
 
     @Test
-    public void calculatesAStdDevOfZeroForASingletonSnapshot() throws Exception {
-        final Snapshot singleItemSnapshot = new UniformSnapshot(new long[]{ 1 });
+    public void calculatesAStdDevOfZeroForASingletonSnapshot() {
+        final Snapshot singleItemSnapshot = new UniformSnapshot(new long[]{1});
 
         assertThat(singleItemSnapshot.getStdDev())
                 .isZero();
diff --git a/metrics-core/src/test/java/com/codahale/metrics/WeightedSnapshotTest.java b/metrics-core/src/test/java/com/codahale/metrics/WeightedSnapshotTest.java
index 3e5a12d..947211c 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/WeightedSnapshotTest.java
+++ b/metrics-core/src/test/java/com/codahale/metrics/WeightedSnapshotTest.java
@@ -1,129 +1,134 @@
 package com.codahale.metrics;
 
+import com.codahale.metrics.WeightedSnapshot.WeightedSample;
 import org.junit.Test;
+import org.mockito.ArgumentMatchers;
 
 import java.io.ByteArrayOutputStream;
 import java.util.ArrayList;
 import java.util.List;
 
-import com.codahale.metrics.WeightedSnapshot.WeightedSample;
-
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.offset;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.when;
 
 public class WeightedSnapshotTest {
-    
-    static public ArrayList<WeightedSample> WeightedArray(long[] values, double[] weights) {
+
+    private static List<WeightedSample> weightedArray(long[] values, double[] weights) {
         if (values.length != weights.length) {
             throw new IllegalArgumentException("Mismatched lengths: " + values.length + " vs " + weights.length);
         }
-        
-        final ArrayList<WeightedSample> samples = new ArrayList<WeightedSnapshot.WeightedSample>();
+
+        final List<WeightedSample> samples = new ArrayList<>();
         for (int i = 0; i < values.length; i++) {
             samples.add(new WeightedSnapshot.WeightedSample(values[i], weights[i]));
         }
-        
+
         return samples;
     }
-    
+
     private final Snapshot snapshot = new WeightedSnapshot(
-            WeightedArray(new long[]{5, 1, 2, 3, 4}, new double[]{1, 2, 3, 2, 2}) );
+            weightedArray(new long[]{5, 1, 2, 3, 4}, new double[]{1, 2, 3, 2, 2}));
 
     @Test
-    public void smallQuantilesAreTheFirstValue() throws Exception {
+    public void smallQuantilesAreTheFirstValue() {
         assertThat(snapshot.getValue(0.0))
                 .isEqualTo(1.0, offset(0.1));
     }
 
     @Test
-    public void bigQuantilesAreTheLastValue() throws Exception {
+    public void bigQuantilesAreTheLastValue() {
         assertThat(snapshot.getValue(1.0))
                 .isEqualTo(5.0, offset(0.1));
     }
 
     @Test(expected = IllegalArgumentException.class)
     public void disallowsNotANumberQuantile() {
-        snapshot.getValue( Double.NaN );
+        snapshot.getValue(Double.NaN);
     }
 
     @Test(expected = IllegalArgumentException.class)
     public void disallowsNegativeQuantile() {
-        snapshot.getValue( -0.5 );
+        snapshot.getValue(-0.5);
     }
 
     @Test(expected = IllegalArgumentException.class)
     public void disallowsQuantileOverOne() {
-        snapshot.getValue( 1.5 );
+        snapshot.getValue(1.5);
     }
 
     @Test
-    public void hasAMedian() throws Exception {
+    public void hasAMedian() {
         assertThat(snapshot.getMedian()).isEqualTo(3.0, offset(0.1));
     }
 
     @Test
-    public void hasAp75() throws Exception {
+    public void hasAp75() {
         assertThat(snapshot.get75thPercentile()).isEqualTo(4.0, offset(0.1));
     }
 
     @Test
-    public void hasAp95() throws Exception {
+    public void hasAp95() {
         assertThat(snapshot.get95thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasAp98() throws Exception {
+    public void hasAp98() {
         assertThat(snapshot.get98thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasAp99() throws Exception {
+    public void hasAp99() {
         assertThat(snapshot.get99thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasAp999() throws Exception {
+    public void hasAp999() {
         assertThat(snapshot.get999thPercentile()).isEqualTo(5.0, offset(0.1));
     }
 
     @Test
-    public void hasValues() throws Exception {
+    public void hasValues() {
         assertThat(snapshot.getValues())
                 .containsOnly(1, 2, 3, 4, 5);
     }
 
     @Test
-    public void hasASize() throws Exception {
+    public void hasASize() {
         assertThat(snapshot.size())
                 .isEqualTo(5);
     }
 
     @Test
-    public void worksWithUnderestimatedCollections() throws Exception {
-        final List<WeightedSample> items = spy(WeightedArray(new long[]{5, 1, 2, 3, 4}, new double[]{1, 2, 3, 2, 2}));
-        when(items.size()).thenReturn(4, 5);
+    public void worksWithUnderestimatedCollections() {
+        final List<WeightedSample> originalItems = weightedArray(new long[]{5, 1, 2, 3, 4}, new double[]{1, 2, 3, 2, 2});
+        final List<WeightedSample> spyItems = spy(originalItems);
+        doReturn(originalItems.toArray(new WeightedSample[]{})).when(spyItems).toArray(ArgumentMatchers.any(WeightedSample[].class));
+        when(spyItems.size()).thenReturn(4, 5);
 
-        final Snapshot other = new WeightedSnapshot(items);
+        final Snapshot other = new WeightedSnapshot(spyItems);
 
         assertThat(other.getValues())
                 .containsOnly(1, 2, 3, 4, 5);
     }
 
     @Test
-    public void worksWithOverestimatedCollections() throws Exception {
-        final List<WeightedSample> items = spy(WeightedArray(new long[]{5, 1, 2, 3, 4}, new double[]{1, 2, 3, 2, 2}));
-        when(items.size()).thenReturn(6, 5);
+    public void worksWithOverestimatedCollections() {
+        final List<WeightedSample> originalItems = weightedArray(new long[]{5, 1, 2, 3, 4}, new double[]{1, 2, 3, 2, 2});
+        final List<WeightedSample> spyItems = spy(originalItems);
+        doReturn(originalItems.toArray(new WeightedSample[]{})).when(spyItems).toArray(ArgumentMatchers.any(WeightedSample[].class));
+        when(spyItems.size()).thenReturn(6, 5);
 
-        final Snapshot other = new WeightedSnapshot(items);
+        final Snapshot other = new WeightedSnapshot(spyItems);
 
         assertThat(other.getValues())
                 .containsOnly(1, 2, 3, 4, 5);
     }
 
     @Test
-    public void dumpsToAStream() throws Exception {
+    public void dumpsToAStream() {
         final ByteArrayOutputStream output = new ByteArrayOutputStream();
 
         snapshot.dump(output);
@@ -133,81 +138,81 @@ public class WeightedSnapshotTest {
     }
 
     @Test
-    public void calculatesTheMinimumValue() throws Exception {
+    public void calculatesTheMinimumValue() {
         assertThat(snapshot.getMin())
                 .isEqualTo(1);
     }
 
     @Test
-    public void calculatesTheMaximumValue() throws Exception {
+    public void calculatesTheMaximumValue() {
         assertThat(snapshot.getMax())
                 .isEqualTo(5);
     }
 
     @Test
-    public void calculatesTheMeanValue() throws Exception {
+    public void calculatesTheMeanValue() {
         assertThat(snapshot.getMean())
                 .isEqualTo(2.7);
     }
 
     @Test
-    public void calculatesTheStdDev() throws Exception {
+    public void calculatesTheStdDev() {
         assertThat(snapshot.getStdDev())
                 .isEqualTo(1.2688, offset(0.0001));
     }
 
     @Test
-    public void calculatesAMinOfZeroForAnEmptySnapshot() throws Exception {
+    public void calculatesAMinOfZeroForAnEmptySnapshot() {
         final Snapshot emptySnapshot = new WeightedSnapshot(
-            WeightedArray(new long[]{}, new double[]{}) );
+                weightedArray(new long[]{}, new double[]{}));
 
         assertThat(emptySnapshot.getMin())
                 .isZero();
     }
 
     @Test
-    public void calculatesAMaxOfZeroForAnEmptySnapshot() throws Exception {
+    public void calculatesAMaxOfZeroForAnEmptySnapshot() {
         final Snapshot emptySnapshot = new WeightedSnapshot(
-            WeightedArray(new long[]{}, new double[]{}) );
+                weightedArray(new long[]{}, new double[]{}));
 
         assertThat(emptySnapshot.getMax())
                 .isZero();
     }
 
     @Test
-    public void calculatesAMeanOfZeroForAnEmptySnapshot() throws Exception {
+    public void calculatesAMeanOfZeroForAnEmptySnapshot() {
         final Snapshot emptySnapshot = new WeightedSnapshot(
-            WeightedArray(new long[]{}, new double[]{}) );
+                weightedArray(new long[]{}, new double[]{}));
 
         assertThat(emptySnapshot.getMean())
                 .isZero();
     }
 
     @Test
-    public void calculatesAStdDevOfZeroForAnEmptySnapshot() throws Exception {
+    public void calculatesAStdDevOfZeroForAnEmptySnapshot() {
         final Snapshot emptySnapshot = new WeightedSnapshot(
-            WeightedArray(new long[]{}, new double[]{}) );
+                weightedArray(new long[]{}, new double[]{}));
 
         assertThat(emptySnapshot.getStdDev())
                 .isZero();
     }
 
     @Test
-    public void calculatesAStdDevOfZeroForASingletonSnapshot() throws Exception {
+    public void calculatesAStdDevOfZeroForASingletonSnapshot() {
         final Snapshot singleItemSnapshot = new WeightedSnapshot(
-            WeightedArray(new long[]{ 1 }, new double[]{ 1.0 }) );
+                weightedArray(new long[]{1}, new double[]{1.0}));
 
         assertThat(singleItemSnapshot.getStdDev())
                 .isZero();
     }
 
     @Test
-    public void expectNoOverflowForLowWeights() throws Exception {
+    public void expectNoOverflowForLowWeights() {
         final Snapshot scatteredSnapshot = new WeightedSnapshot(
-            WeightedArray(
-                    new long[]{ 1, 2, 3 }, 
-                    new double[]{ Double.MIN_VALUE, Double.MIN_VALUE, Double.MIN_VALUE }
-            ) 
+                weightedArray(
+                        new long[]{1, 2, 3},
+                        new double[]{Double.MIN_VALUE, Double.MIN_VALUE, Double.MIN_VALUE}
+                )
         );
 
         assertThat(scatteredSnapshot.getMean())
@@ -217,7 +222,7 @@ public class WeightedSnapshotTest {
     @Test
     public void doesNotProduceNaNValues() {
         WeightedSnapshot weightedSnapshot = new WeightedSnapshot(
-                WeightedArray(new long[]{1, 2, 3}, new double[]{0, 0, 0}));
+                weightedArray(new long[]{1, 2, 3}, new double[]{0, 0, 0}));
         assertThat(weightedSnapshot.getMean()).isEqualTo(0);
     }
 
diff --git a/metrics-ehcache/pom.xml b/metrics-ehcache/pom.xml
index 771eab8..329e5cc 100644
--- a/metrics-ehcache/pom.xml
+++ b/metrics-ehcache/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-ehcache</artifactId>
@@ -15,16 +15,32 @@
         An Ehcache wrapper providing Metrics instrumentation of caches.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.ehcache</javaModuleName>
+        <ehcache2.version>2.10.9.2</ehcache2.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>net.sf.ehcache</groupId>
             <artifactId>ehcache</artifactId>
-            <version>2.8.3</version>
+            <version>${ehcache2.version}</version>
             <exclusions>
                 <exclusion>
                     <groupId>org.slf4j</groupId>
@@ -32,5 +48,23 @@
                 </exclusion>
             </exclusions>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/metrics-ehcache/src/main/java/com/codahale/metrics/ehcache/InstrumentedEhcache.java b/metrics-ehcache/src/main/java/com/codahale/metrics/ehcache/InstrumentedEhcache.java
index 69fa1eb..4ffcc92 100644
--- a/metrics-ehcache/src/main/java/com/codahale/metrics/ehcache/InstrumentedEhcache.java
+++ b/metrics-ehcache/src/main/java/com/codahale/metrics/ehcache/InstrumentedEhcache.java
@@ -1,6 +1,5 @@
 package com.codahale.metrics.ehcache;
 
-import com.codahale.metrics.Gauge;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.Timer;
 import net.sf.ehcache.CacheException;
@@ -20,7 +19,7 @@ public class InstrumentedEhcache extends EhcacheDecoratorAdapter {
     /**
      * Instruments the given {@link Ehcache} instance with get and put timers
      * and a set of gauges for Ehcache's built-in statistics:
-     * <p/>
+     * <p>
      * <table>
      * <caption>Ehcache timered metrics</caption>
      * <tr>
@@ -106,153 +105,68 @@ public class InstrumentedEhcache extends EhcacheDecoratorAdapter {
      * "None", "Best Effort" or "Guaranteed".</td>
      * </tr>
      * </table>
-     *
+     * <p>
      * <b>N.B.: This enables Ehcache's sampling statistics with an accuracy
      * level of "none."</b>
      *
-     * @param cache       an {@link Ehcache} instance
-     * @param registry    a {@link MetricRegistry}
+     * @param cache    an {@link Ehcache} instance
+     * @param registry a {@link MetricRegistry}
      * @return an instrumented decorator for {@code cache}
      * @see StatisticsGateway
      */
     public static Ehcache instrument(MetricRegistry registry, final Ehcache cache) {
 
         final String prefix = name(cache.getClass(), cache.getName());
-        registry.register(name(prefix, "hits"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().cacheHitCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "hits"),
+                () -> cache.getStatistics().cacheHitCount());
 
-        registry.register(name(prefix, "in-memory-hits"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().localHeapHitCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "in-memory-hits"),
+                () -> cache.getStatistics().localHeapHitCount());
 
-        registry.register(name(prefix, "off-heap-hits"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().localOffHeapHitCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "off-heap-hits"),
+                () -> cache.getStatistics().localOffHeapHitCount());
 
-        registry.register(name(prefix, "on-disk-hits"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().localDiskHitCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "on-disk-hits"),
+                () -> cache.getStatistics().localDiskHitCount());
 
-        registry.register(name(prefix, "misses"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().cacheMissCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "misses"),
+                () -> cache.getStatistics().cacheMissCount());
 
-        registry.register(name(prefix, "in-memory-misses"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().localHeapMissCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "in-memory-misses"),
+                () -> cache.getStatistics().localHeapMissCount());
 
-        registry.register(name(prefix, "off-heap-misses"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().localOffHeapMissCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "off-heap-misses"),
+                () -> cache.getStatistics().localOffHeapMissCount());
 
-        registry.register(name(prefix, "on-disk-misses"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().localDiskMissCount();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "on-disk-misses"),
+                () -> cache.getStatistics().localDiskMissCount());
 
-        registry.register(name(prefix, "objects"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().getSize();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "objects"),
+                () -> cache.getStatistics().getSize());
 
-        registry.register(name(prefix, "in-memory-objects"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().getLocalHeapSize();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "in-memory-objects"),
+                () -> cache.getStatistics().getLocalHeapSize());
 
-        registry.register(name(prefix, "off-heap-objects"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().getLocalOffHeapSize();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "off-heap-objects"),
+                () -> cache.getStatistics().getLocalOffHeapSize());
 
-        registry.register(name(prefix, "on-disk-objects"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().getLocalDiskSize();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "on-disk-objects"),
+                () -> cache.getStatistics().getLocalDiskSize());
 
-        registry.register(name(prefix, "mean-get-time"),
-                          new Gauge<Double>() {
-                              @Override
-                              public Double getValue() {
-                                  return cache.getStatistics().cacheGetOperation().latency().average().value();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "mean-get-time"),
+                () -> cache.getStatistics().cacheGetOperation().latency().average().value());
 
-        registry.register(name(prefix, "mean-search-time"),
-                          new Gauge<Double>() {
-                              @Override
-                              public Double getValue() {
-                                  return cache.getStatistics().cacheSearchOperation().latency().average().value();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "mean-search-time"),
+                () -> cache.getStatistics().cacheSearchOperation().latency().average().value());
 
-        registry.register(name(prefix, "eviction-count"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().cacheEvictionOperation().count().value();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "eviction-count"),
+                () -> cache.getStatistics().cacheEvictionOperation().count().value());
 
-        registry.register(name(prefix, "searches-per-second"),
-                          new Gauge<Double>() {
-                              @Override
-                              public Double getValue() {
-                                  return cache.getStatistics().cacheSearchOperation().rate().value();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "searches-per-second"),
+                () -> cache.getStatistics().cacheSearchOperation().rate().value());
 
-        registry.register(name(prefix, "writer-queue-size"),
-                          new Gauge<Long>() {
-                              @Override
-                              public Long getValue() {
-                                  return cache.getStatistics().getWriterQueueLength();
-                              }
-                          });
+        registry.registerGauge(name(prefix, "writer-queue-size"),
+                () -> cache.getStatistics().getWriterQueueLength());
 
         return new InstrumentedEhcache(registry, cache);
     }
diff --git a/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedCacheDecoratorFactoryTest.java b/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedCacheDecoratorFactoryTest.java
index f004320..c9177e0 100644
--- a/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedCacheDecoratorFactoryTest.java
+++ b/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedCacheDecoratorFactoryTest.java
@@ -6,14 +6,12 @@ import net.sf.ehcache.Cache;
 import net.sf.ehcache.CacheManager;
 import net.sf.ehcache.Ehcache;
 import net.sf.ehcache.Element;
-import org.hamcrest.CoreMatchers;
 import org.junit.Before;
 import org.junit.Test;
 
 import static com.codahale.metrics.MetricRegistry.name;
+import static java.util.Objects.requireNonNull;
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.hamcrest.CoreMatchers.is;
-import static org.junit.Assume.assumeThat;
 
 public class InstrumentedCacheDecoratorFactoryTest {
     private static final CacheManager MANAGER = CacheManager.create();
@@ -22,15 +20,13 @@ public class InstrumentedCacheDecoratorFactoryTest {
     private Ehcache cache;
 
     @Before
-    public void setUp() throws Exception {
-        this.cache = MANAGER.getEhcache("test-config");
-        assumeThat(cache, is(CoreMatchers.notNullValue()));
-
+    public void setUp() {
+        this.cache = requireNonNull(MANAGER.getEhcache("test-config"));
         this.registry = SharedMetricRegistries.getOrCreate("cache-metrics");
     }
 
     @Test
-    public void measuresGets() throws Exception {
+    public void measuresGets() {
         cache.get("woo");
 
         assertThat(registry.timer(name(Cache.class, "test-config", "gets")).getCount())
@@ -39,7 +35,7 @@ public class InstrumentedCacheDecoratorFactoryTest {
     }
 
     @Test
-    public void measuresPuts() throws Exception {
+    public void measuresPuts() {
         cache.put(new Element("woo", "whee"));
 
         assertThat(registry.timer(name(Cache.class, "test-config", "puts")).getCount())
diff --git a/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedEhcacheTest.java b/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedEhcacheTest.java
index d97dec3..a2f8636 100644
--- a/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedEhcacheTest.java
+++ b/metrics-ehcache/src/test/java/com/codahale/metrics/ehcache/InstrumentedEhcacheTest.java
@@ -12,6 +12,7 @@ import org.junit.Test;
 
 import static com.codahale.metrics.MetricRegistry.name;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
 
 public class InstrumentedEhcacheTest {
     private static final CacheManager MANAGER = CacheManager.create();
@@ -20,14 +21,35 @@ public class InstrumentedEhcacheTest {
     private Ehcache cache;
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         final Cache c = new Cache(new CacheConfiguration("test", 100));
         MANAGER.addCache(c);
         this.cache = InstrumentedEhcache.instrument(registry, c);
+        assertThat(registry.getGauges().entrySet().stream()
+                .map(e -> entry(e.getKey(), e.getValue().getValue())))
+                .containsOnly(
+                        entry("net.sf.ehcache.Cache.test.eviction-count", 0L),
+                        entry("net.sf.ehcache.Cache.test.hits", 0L),
+                        entry("net.sf.ehcache.Cache.test.in-memory-hits", 0L),
+                        entry("net.sf.ehcache.Cache.test.in-memory-misses", 0L),
+                        entry("net.sf.ehcache.Cache.test.in-memory-objects", 0L),
+                        entry("net.sf.ehcache.Cache.test.mean-get-time", Double.NaN),
+                        entry("net.sf.ehcache.Cache.test.mean-search-time", Double.NaN),
+                        entry("net.sf.ehcache.Cache.test.misses", 0L),
+                        entry("net.sf.ehcache.Cache.test.objects", 0L),
+                        entry("net.sf.ehcache.Cache.test.off-heap-hits", 0L),
+                        entry("net.sf.ehcache.Cache.test.off-heap-misses", 0L),
+                        entry("net.sf.ehcache.Cache.test.off-heap-objects", 0L),
+                        entry("net.sf.ehcache.Cache.test.on-disk-hits", 0L),
+                        entry("net.sf.ehcache.Cache.test.on-disk-misses", 0L),
+                        entry("net.sf.ehcache.Cache.test.on-disk-objects", 0L),
+                        entry("net.sf.ehcache.Cache.test.searches-per-second", 0.0),
+                        entry("net.sf.ehcache.Cache.test.writer-queue-size", 0L)
+                );
     }
 
     @Test
-    public void measuresGetsAndPuts() throws Exception {
+    public void measuresGetsAndPuts() {
         cache.get("woo");
 
         cache.put(new Element("woo", "whee"));
diff --git a/metrics-ehcache/src/test/resources/ehcache.xml b/metrics-ehcache/src/test/resources/ehcache.xml
index 9c6c145..6e790fd 100644
--- a/metrics-ehcache/src/test/resources/ehcache.xml
+++ b/metrics-ehcache/src/test/resources/ehcache.xml
@@ -4,16 +4,16 @@
          monitoring="autodetect" dynamicConfig="true">
 
     <cache name="test-config"
-    maxElementsInMemory="30"
-    eternal="false"
-    overflowToDisk="false"
-    timeToIdleSeconds="1000"
-    timeToLiveSeconds="3600"
-    memoryStoreEvictionPolicy="LFU"
-    transactionalMode="off">
+           maxElementsInMemory="30"
+           eternal="false"
+           overflowToDisk="false"
+           timeToIdleSeconds="1000"
+           timeToLiveSeconds="3600"
+           memoryStoreEvictionPolicy="LFU"
+           transactionalMode="off">
 
-    <cacheDecoratorFactory class="com.codahale.metrics.ehcache.InstrumentedCacheDecoratorFactory"
-                           properties="metric-registry-name=cache-metrics" />
+        <cacheDecoratorFactory class="com.codahale.metrics.ehcache.InstrumentedCacheDecoratorFactory"
+                               properties="metric-registry-name=cache-metrics"/>
 
     </cache>
 
diff --git a/metrics-ganglia/pom.xml b/metrics-ganglia/pom.xml
deleted file mode 100644
index 0811d1e..0000000
--- a/metrics-ganglia/pom.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<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>io.dropwizard.metrics</groupId>
-        <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
-    </parent>
-
-    <artifactId>metrics-ganglia</artifactId>
-    <name>Ganglia Integration for Metrics</name>
-    <packaging>bundle</packaging>
-    <description>
-        A reporter for Metrics which announces measurements to a Ganglia cluster.
-    </description>
-
-    <dependencies>
-        <dependency>
-            <groupId>io.dropwizard.metrics</groupId>
-            <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>info.ganglia.gmetric4j</groupId>
-            <artifactId>gmetric4j</artifactId>
-            <version>1.0.7</version>
-        </dependency>
-    </dependencies>
-</project>
diff --git a/metrics-ganglia/src/main/java/com/codahale/metrics/ganglia/GangliaReporter.java b/metrics-ganglia/src/main/java/com/codahale/metrics/ganglia/GangliaReporter.java
deleted file mode 100644
index 2e0fd29..0000000
--- a/metrics-ganglia/src/main/java/com/codahale/metrics/ganglia/GangliaReporter.java
+++ /dev/null
@@ -1,422 +0,0 @@
-package com.codahale.metrics.ganglia;
-
-import com.codahale.metrics.*;
-import com.codahale.metrics.MetricAttribute;
-import info.ganglia.gmetric4j.gmetric.GMetric;
-import info.ganglia.gmetric4j.gmetric.GMetricSlope;
-import info.ganglia.gmetric4j.gmetric.GMetricType;
-import info.ganglia.gmetric4j.gmetric.GangliaException;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.Collections;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedMap;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.regex.Pattern;
-
-import static com.codahale.metrics.MetricRegistry.name;
-import static com.codahale.metrics.MetricAttribute.*;
-
-/**
- * A reporter which announces metric values to a Ganglia cluster.
- *
- * @see <a href="http://ganglia.sourceforge.net/">Ganglia Monitoring System</a>
- */
-public class GangliaReporter extends ScheduledReporter {
-
-    private static final Pattern SLASHES = Pattern.compile("\\\\");
-
-    /**
-     * Returns a new {@link Builder} for {@link GangliaReporter}.
-     *
-     * @param registry the registry to report
-     * @return a {@link Builder} instance for a {@link GangliaReporter}
-     */
-    public static Builder forRegistry(MetricRegistry registry) {
-        return new Builder(registry);
-    }
-
-    /**
-     * A builder for {@link GangliaReporter} instances. Defaults to using a {@code tmax} of {@code 60},
-     * a {@code dmax} of {@code 0}, converting rates to events/second, converting durations to
-     * milliseconds, and not filtering metrics.
-     */
-    public static class Builder {
-        private final MetricRegistry registry;
-        private String prefix;
-        private int tMax;
-        private int dMax;
-        private TimeUnit rateUnit;
-        private TimeUnit durationUnit;
-        private MetricFilter filter;
-        private ScheduledExecutorService executor;
-        private boolean shutdownExecutorOnStop;
-        private Set<MetricAttribute> disabledMetricAttributes = Collections.emptySet();
-
-        private Builder(MetricRegistry registry) {
-            this.registry = registry;
-            this.tMax = 60;
-            this.dMax = 0;
-            this.rateUnit = TimeUnit.SECONDS;
-            this.durationUnit = TimeUnit.MILLISECONDS;
-            this.filter = MetricFilter.ALL;
-            this.executor = null;
-            this.shutdownExecutorOnStop = true;
-        }
-
-        /**
-         * Specifies whether or not, the executor (used for reporting) will be stopped with same time with reporter.
-         * Default value is true.
-         * Setting this parameter to false, has the sense in combining with providing external managed executor via {@link #scheduleOn(ScheduledExecutorService)}.
-         *
-         * @param shutdownExecutorOnStop if true, then executor will be stopped in same time with this reporter
-         * @return {@code this}
-         */
-        public Builder shutdownExecutorOnStop(boolean shutdownExecutorOnStop) {
-            this.shutdownExecutorOnStop = shutdownExecutorOnStop;
-            return this;
-        }
-
-        /**
-         * Specifies the executor to use while scheduling reporting of metrics.
-         * Default value is null.
-         * Null value leads to executor will be auto created on start.
-         *
-         * @param executor the executor to use while scheduling reporting of metrics.
-         * @return {@code this}
-         */
-        public Builder scheduleOn(ScheduledExecutorService executor) {
-            this.executor = executor;
-            return this;
-        }
-
-        /**
-         * Use the given {@code tmax} value when announcing metrics.
-         *
-         * @param tMax the desired gmond {@code tmax} value
-         * @return {@code this}
-         */
-        public Builder withTMax(int tMax) {
-            this.tMax = tMax;
-            return this;
-        }
-
-        /**
-         * Prefix all metric names with the given string.
-         *
-         * @param prefix the prefix for all metric names
-         * @return {@code this}
-         */
-        public Builder prefixedWith(String prefix) {
-            this.prefix = prefix;
-            return this;
-        }
-
-        /**
-         * Use the given {@code dmax} value when announcing metrics.
-         *
-         * @param dMax the desired gmond {@code dmax} value
-         * @return {@code this}
-         */
-        public Builder withDMax(int dMax) {
-            this.dMax = dMax;
-            return this;
-        }
-
-        /**
-         * Convert rates to the given time unit.
-         *
-         * @param rateUnit a unit of time
-         * @return {@code this}
-         */
-        public Builder convertRatesTo(TimeUnit rateUnit) {
-            this.rateUnit = rateUnit;
-            return this;
-        }
-
-        /**
-         * Convert durations to the given time unit.
-         *
-         * @param durationUnit a unit of time
-         * @return {@code this}
-         */
-        public Builder convertDurationsTo(TimeUnit durationUnit) {
-            this.durationUnit = durationUnit;
-            return this;
-        }
-
-        /**
-         * Only report metrics which match the given filter.
-         *
-         * @param filter a {@link MetricFilter}
-         * @return {@code this}
-         */
-        public Builder filter(MetricFilter filter) {
-            this.filter = filter;
-            return this;
-        }
-
-        /**
-         * Don't report the passed metric attributes for all metrics (e.g. "p999", "stddev" or "m15").
-         * See {@link MetricAttribute}.
-         *
-         * @param disabledMetricAttributes a {@link MetricFilter}
-         * @return {@code this}
-         */
-        public Builder disabledMetricAttributes(Set<MetricAttribute> disabledMetricAttributes) {
-            this.disabledMetricAttributes = disabledMetricAttributes;
-            return this;
-        }
-
-        /**
-         * Builds a {@link GangliaReporter} with the given properties, announcing metrics to the
-         * given {@link GMetric} client.
-         *
-         * @param gmetric the client to use for announcing metrics
-         * @return a {@link GangliaReporter}
-         */
-        public GangliaReporter build(GMetric gmetric) {
-            return new GangliaReporter(registry, gmetric, null, prefix, tMax, dMax, rateUnit, durationUnit, filter,
-                    executor, shutdownExecutorOnStop, disabledMetricAttributes);
-        }
-
-        /**
-         * Builds a {@link GangliaReporter} with the given properties, announcing metrics to the
-         * given {@link GMetric} client.
-         *
-         * @param gmetrics the clients to use for announcing metrics
-         * @return a {@link GangliaReporter}
-         */
-        public GangliaReporter build(GMetric... gmetrics) {
-            return new GangliaReporter(registry, null, gmetrics, prefix, tMax, dMax, rateUnit, durationUnit,
-                    filter, executor, shutdownExecutorOnStop , disabledMetricAttributes);
-        }
-    }
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(GangliaReporter.class);
-
-    private final GMetric gmetric;
-    private final GMetric[] gmetrics;
-    private final String prefix;
-    private final int tMax;
-    private final int dMax;
-
-    private GangliaReporter(MetricRegistry registry,
-                            GMetric gmetric,
-                            GMetric[] gmetrics,
-                            String prefix,
-                            int tMax,
-                            int dMax,
-                            TimeUnit rateUnit,
-                            TimeUnit durationUnit,
-                            MetricFilter filter,
-                            ScheduledExecutorService executor,
-                            boolean shutdownExecutorOnStop,
-                            Set<MetricAttribute> disabledMetricAttributes) {
-        super(registry, "ganglia-reporter", filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop,
-                disabledMetricAttributes);
-        this.gmetric = gmetric;
-        this.gmetrics = gmetrics;
-        this.prefix = prefix;
-        this.tMax = tMax;
-        this.dMax = dMax;
-    }
-
-    @Override
-    public void report(SortedMap<String, Gauge> gauges,
-                       SortedMap<String, Counter> counters,
-                       SortedMap<String, Histogram> histograms,
-                       SortedMap<String, Meter> meters,
-                       SortedMap<String, Timer> timers) {
-        for (Map.Entry<String, Gauge> entry : gauges.entrySet()) {
-            reportGauge(entry.getKey(), entry.getValue());
-        }
-
-        for (Map.Entry<String, Counter> entry : counters.entrySet()) {
-            reportCounter(entry.getKey(), entry.getValue());
-        }
-
-        for (Map.Entry<String, Histogram> entry : histograms.entrySet()) {
-            reportHistogram(entry.getKey(), entry.getValue());
-        }
-
-        for (Map.Entry<String, Meter> entry : meters.entrySet()) {
-            reportMeter(entry.getKey(), entry.getValue());
-        }
-
-        for (Map.Entry<String, Timer> entry : timers.entrySet()) {
-            reportTimer(entry.getKey(), entry.getValue());
-        }
-    }
-
-    private void reportTimer(String name, Timer timer) {
-        final String sanitizedName = escapeSlashes(name);
-        final String group = group(name);
-        try {
-            final Snapshot snapshot = timer.getSnapshot();
-
-            announceIfEnabled(MAX, sanitizedName, group, convertDuration(snapshot.getMax()), getDurationUnit());
-            announceIfEnabled(MEAN, sanitizedName, group, convertDuration(snapshot.getMean()), getDurationUnit());
-            announceIfEnabled(MIN, sanitizedName, group, convertDuration(snapshot.getMin()), getDurationUnit());
-            announceIfEnabled(STDDEV, sanitizedName, group, convertDuration(snapshot.getStdDev()), getDurationUnit());
-
-            announceIfEnabled(P50, sanitizedName, group, convertDuration(snapshot.getMedian()), getDurationUnit());
-            announceIfEnabled(P75, sanitizedName,
-                     group,
-                     convertDuration(snapshot.get75thPercentile()),
-                     getDurationUnit());
-            announceIfEnabled(P95, sanitizedName,
-                     group,
-                     convertDuration(snapshot.get95thPercentile()),
-                     getDurationUnit());
-            announceIfEnabled(P98, sanitizedName,
-                     group,
-                     convertDuration(snapshot.get98thPercentile()),
-                     getDurationUnit());
-            announceIfEnabled(P99, sanitizedName,
-                     group,
-                     convertDuration(snapshot.get99thPercentile()),
-                     getDurationUnit());
-            announceIfEnabled(P999, sanitizedName,
-                     group,
-                     convertDuration(snapshot.get999thPercentile()),
-                     getDurationUnit());
-
-            reportMetered(sanitizedName, timer, group, "calls");
-        } catch (GangliaException e) {
-            LOGGER.warn("Unable to report timer {}", sanitizedName, e);
-        }
-    }
-
-    private void reportMeter(String name, Meter meter) {
-        final String sanitizedName = escapeSlashes(name);
-        final String group = group(name);
-        try {
-            reportMetered(sanitizedName, meter, group, "events");
-        } catch (GangliaException e) {
-            LOGGER.warn("Unable to report meter {}", name, e);
-        }
-    }
-
-    private void reportMetered(String name, Metered meter, String group, String eventName) throws GangliaException {
-        final String unit = eventName + '/' + getRateUnit();
-        announceIfEnabled(COUNT, name, group, meter.getCount(), eventName);
-        announceIfEnabled(M1_RATE, name, group, convertRate(meter.getOneMinuteRate()), unit);
-        announceIfEnabled(M5_RATE, name, group, convertRate(meter.getFiveMinuteRate()), unit);
-        announceIfEnabled(M15_RATE, name, group, convertRate(meter.getFifteenMinuteRate()), unit);
-        announceIfEnabled(MEAN_RATE, name, group, convertRate(meter.getMeanRate()), unit);
-    }
-
-    private void reportHistogram(String name, Histogram histogram) {
-        final String sanitizedName = escapeSlashes(name);
-        final String group = group(name);
-        try {
-            final Snapshot snapshot = histogram.getSnapshot();
-
-            announceIfEnabled(COUNT, sanitizedName, group, histogram.getCount(), "");
-            announceIfEnabled(MAX, sanitizedName, group, snapshot.getMax(), "");
-            announceIfEnabled(MEAN, sanitizedName, group, snapshot.getMean(), "");
-            announceIfEnabled(MIN, sanitizedName, group, snapshot.getMin(), "");
-            announceIfEnabled(STDDEV, sanitizedName, group, snapshot.getStdDev(), "");
-            announceIfEnabled(P50, sanitizedName, group, snapshot.getMedian(), "");
-            announceIfEnabled(P75, sanitizedName, group, snapshot.get75thPercentile(), "");
-            announceIfEnabled(P95, sanitizedName, group, snapshot.get95thPercentile(), "");
-            announceIfEnabled(P98, sanitizedName, group, snapshot.get98thPercentile(), "");
-            announceIfEnabled(P99, sanitizedName, group, snapshot.get99thPercentile(), "");
-            announceIfEnabled(P999, sanitizedName, group, snapshot.get999thPercentile(), "");
-        } catch (GangliaException e) {
-            LOGGER.warn("Unable to report histogram {}", sanitizedName, e);
-        }
-    }
-
-    private void reportCounter(String name, Counter counter) {
-        final String sanitizedName = escapeSlashes(name);
-        final String group = group(name);
-        try {
-            announce(prefix(sanitizedName, COUNT.getCode()), group, Long.toString(counter.getCount()), GMetricType.DOUBLE, "");
-        } catch (GangliaException e) {
-            LOGGER.warn("Unable to report counter {}", name, e);
-        }
-    }
-
-    private void reportGauge(String name, Gauge gauge) {
-        final String sanitizedName = escapeSlashes(name);
-        final String group = group(name);
-        final Object obj = gauge.getValue();
-        final String value = String.valueOf(obj);
-        final GMetricType type = detectType(obj);
-        try {
-            announce(name(prefix, sanitizedName), group, value, type, "");
-        } catch (GangliaException e) {
-            LOGGER.warn("Unable to report gauge {}", name, e);
-        }
-    }
-
-    private static final double MIN_VAL = 1E-300;
-
-    private void announceIfEnabled(MetricAttribute metricAttribute, String metricName, String group, double value, String units)
-            throws GangliaException {
-        if (getDisabledMetricAttributes().contains(metricAttribute)) {
-            return;
-        }
-        final String string = Math.abs(value) < MIN_VAL ? "0" : Double.toString(value);
-        announce(prefix(metricName, metricAttribute.getCode()), group, string, GMetricType.DOUBLE, units);
-    }
-
-    private void announceIfEnabled(MetricAttribute metricAttribute, String metricName, String group, long value, String units)
-            throws GangliaException {
-        if (getDisabledMetricAttributes().contains(metricAttribute)) {
-            return;
-        }
-        announce(prefix(metricName, metricAttribute.getCode()), group, Long.toString(value), GMetricType.DOUBLE, units);
-    }
-
-    private void announce(String name, String group, String value, GMetricType type, String units)
-            throws GangliaException {
-        if (gmetric != null) {
-            gmetric.announce(name, value, type, units, GMetricSlope.BOTH, tMax, dMax, group);
-        } else {
-            for (GMetric gmetric : gmetrics) {
-                gmetric.announce(name, value, type, units, GMetricSlope.BOTH, tMax, dMax, group);
-            }
-        }
-    }
-
-    private GMetricType detectType(Object o) {
-        if (o instanceof Float) {
-            return GMetricType.FLOAT;
-        } else if (o instanceof Double) {
-            return GMetricType.DOUBLE;
-        } else if (o instanceof Byte) {
-            return GMetricType.INT8;
-        } else if (o instanceof Short) {
-            return GMetricType.INT16;
-        } else if (o instanceof Integer) {
-            return GMetricType.INT32;
-        } else if (o instanceof Long) {
-            return GMetricType.DOUBLE;
-        }
-        return GMetricType.STRING;
-    }
-
-    private String group(String name) {
-        final int i = name.lastIndexOf('.');
-        if (i < 0) {
-            return "";
-        }
-        return name.substring(0, i);
-    }
-
-    private String prefix(String name, String n) {
-        return name(prefix, name, n);
-    }
-
-    // ganglia metric names can't contain slashes.
-    private String escapeSlashes(String name) {
-        return SLASHES.matcher(name).replaceAll("_");
-    }
-}
diff --git a/metrics-ganglia/src/test/java/com/codahale/metrics/ganglia/GangliaReporterTest.java b/metrics-ganglia/src/test/java/com/codahale/metrics/ganglia/GangliaReporterTest.java
deleted file mode 100644
index 470bd58..0000000
--- a/metrics-ganglia/src/test/java/com/codahale/metrics/ganglia/GangliaReporterTest.java
+++ /dev/null
@@ -1,303 +0,0 @@
-package com.codahale.metrics.ganglia;
-
-import com.codahale.metrics.*;
-import info.ganglia.gmetric4j.gmetric.GMetric;
-import info.ganglia.gmetric4j.gmetric.GMetricSlope;
-import info.ganglia.gmetric4j.gmetric.GMetricType;
-import org.junit.Test;
-
-import java.util.EnumSet;
-import java.util.SortedMap;
-import java.util.TreeMap;
-import java.util.concurrent.TimeUnit;
-
-import static org.mockito.Mockito.*;
-
-public class GangliaReporterTest {
-    private final GMetric ganglia = mock(GMetric.class);
-    private final MetricRegistry registry = mock(MetricRegistry.class);
-    private final GangliaReporter reporter = GangliaReporter.forRegistry(registry)
-                                                            .prefixedWith("m")
-                                                            .withTMax(60)
-                                                            .withDMax(0)
-                                                            .convertRatesTo(TimeUnit.SECONDS)
-                                                            .convertDurationsTo(TimeUnit.MILLISECONDS)
-                                                            .filter(MetricFilter.ALL)
-                                                            .build(ganglia);
-
-    @Test
-    public void reportsStringGaugeValues() throws Exception {
-        reporter.report(map("gauge", gauge("value")),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge", "value", GMetricType.STRING, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void escapeSlashesInMetricNames() throws Exception {
-        reporter.report(map("gauge_with\\slashes", gauge("value")),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge_with_slashes", "value", GMetricType.STRING, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsByteGaugeValues() throws Exception {
-        reporter.report(map("gauge", gauge((byte) 1)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge", "1", GMetricType.INT8, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsShortGaugeValues() throws Exception {
-        reporter.report(map("gauge", gauge((short) 1)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge", "1", GMetricType.INT16, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsIntegerGaugeValues() throws Exception {
-        reporter.report(map("gauge", gauge(1)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge", "1", GMetricType.INT32, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsLongGaugeValues() throws Exception {
-        reporter.report(map("gauge", gauge(1L)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge", "1", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsFloatGaugeValues() throws Exception {
-        reporter.report(map("gauge", gauge(1.0f)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge", "1.0", GMetricType.FLOAT, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsDoubleGaugeValues() throws Exception {
-        reporter.report(map("gauge", gauge(1.0)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.gauge", "1.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsCounterValues() throws Exception {
-        final Counter counter = mock(Counter.class);
-        when(counter.getCount()).thenReturn(100L);
-
-        reporter.report(this.<Gauge>map(),
-                        map("test.counter", counter),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.test.counter.count", "100", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsHistogramValues() throws Exception {
-        final Histogram histogram = mock(Histogram.class);
-        when(histogram.getCount()).thenReturn(1L);
-
-        final Snapshot snapshot = mock(Snapshot.class);
-        when(snapshot.getMax()).thenReturn(2L);
-        when(snapshot.getMean()).thenReturn(3.0);
-        when(snapshot.getMin()).thenReturn(4L);
-        when(snapshot.getStdDev()).thenReturn(5.0);
-        when(snapshot.getMedian()).thenReturn(6.0);
-        when(snapshot.get75thPercentile()).thenReturn(7.0);
-        when(snapshot.get95thPercentile()).thenReturn(8.0);
-        when(snapshot.get98thPercentile()).thenReturn(9.0);
-        when(snapshot.get99thPercentile()).thenReturn(10.0);
-        when(snapshot.get999thPercentile()).thenReturn(11.0);
-
-        when(histogram.getSnapshot()).thenReturn(snapshot);
-
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        map("test.histogram", histogram),
-                        this.<Meter>map(),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.test.histogram.count", "1", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.max", "2", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.mean", "3.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.min", "4", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.stddev", "5.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.p50", "6.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.p75", "7.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.p95", "8.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.p98", "9.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.p99", "10.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.histogram.p999", "11.0", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0, "test");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsMeterValues() throws Exception {
-        final Meter meter = mock(Meter.class);
-        when(meter.getCount()).thenReturn(1L);
-        when(meter.getMeanRate()).thenReturn(2.0);
-        when(meter.getOneMinuteRate()).thenReturn(3.0);
-        when(meter.getFiveMinuteRate()).thenReturn(4.0);
-        when(meter.getFifteenMinuteRate()).thenReturn(5.0);
-
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        map("test.meter", meter),
-                        this.<Timer>map());
-
-        verify(ganglia).announce("m.test.meter.count", "1", GMetricType.DOUBLE, "events", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.meter.mean_rate", "2.0", GMetricType.DOUBLE, "events/second", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.meter.m1_rate", "3.0", GMetricType.DOUBLE, "events/second", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.meter.m5_rate", "4.0", GMetricType.DOUBLE, "events/second", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.meter.m15_rate", "5.0", GMetricType.DOUBLE, "events/second", GMetricSlope.BOTH, 60, 0, "test");
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void reportsTimerValues() throws Exception {
-        final Timer timer = mock(Timer.class);
-        when(timer.getCount()).thenReturn(1L);
-
-        when(timer.getMeanRate()).thenReturn(2.0);
-        when(timer.getOneMinuteRate()).thenReturn(3.0);
-        when(timer.getFiveMinuteRate()).thenReturn(4.0);
-        when(timer.getFifteenMinuteRate()).thenReturn(5.0);
-
-        final Snapshot snapshot = mock(Snapshot.class);
-        when(snapshot.getMax()).thenReturn(TimeUnit.MILLISECONDS.toNanos(100));
-        when(snapshot.getMean()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(200));
-        when(snapshot.getMin()).thenReturn(TimeUnit.MILLISECONDS.toNanos(300));
-        when(snapshot.getStdDev()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(400));
-        when(snapshot.getMedian()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(500));
-        when(snapshot.get75thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(600));
-        when(snapshot.get95thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(700));
-        when(snapshot.get98thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(800));
-        when(snapshot.get99thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(900));
-        when(snapshot.get999thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(1000));
-
-        when(timer.getSnapshot()).thenReturn(snapshot);
-
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        map("test.another.timer", timer));
-
-        verify(ganglia).announce("m.test.another.timer.max", "100.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.mean", "200.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.min", "300.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.stddev", "400.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.p50", "500.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.p75", "600.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.p95", "700.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.p98", "800.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.p99", "900.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.p999", "1000.0", GMetricType.DOUBLE, "milliseconds", GMetricSlope.BOTH, 60, 0, "test.another");
-
-        verify(ganglia).announce("m.test.another.timer.count", "1", GMetricType.DOUBLE, "calls", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.mean_rate", "2.0", GMetricType.DOUBLE, "calls/second", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.m1_rate", "3.0", GMetricType.DOUBLE, "calls/second", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.m5_rate", "4.0", GMetricType.DOUBLE, "calls/second", GMetricSlope.BOTH, 60, 0, "test.another");
-        verify(ganglia).announce("m.test.another.timer.m15_rate", "5.0", GMetricType.DOUBLE, "calls/second", GMetricSlope.BOTH, 60, 0, "test.another");
-
-        verifyNoMoreInteractions(ganglia);
-    }
-
-    @Test
-    public void disabledMetricAttributes() throws Exception {
-        final Meter meter = mock(Meter.class);
-        final Counter counter = mock(Counter.class);
-
-        when(meter.getCount()).thenReturn(1L);
-        when(meter.getMeanRate()).thenReturn(2.0);
-        when(meter.getOneMinuteRate()).thenReturn(3.0);
-        when(meter.getFiveMinuteRate()).thenReturn(4.0);
-        when(meter.getFifteenMinuteRate()).thenReturn(5.0);
-
-        when(counter.getCount()).thenReturn(1L);
-
-        GangliaReporter reporter = GangliaReporter.forRegistry(registry)
-                .prefixedWith("m")
-                .withTMax(60)
-                .withDMax(0)
-                .convertRatesTo(TimeUnit.SECONDS)
-                .convertDurationsTo(TimeUnit.MILLISECONDS)
-                .filter(MetricFilter.ALL)
-                .disabledMetricAttributes(EnumSet.of(MetricAttribute.COUNT, MetricAttribute.MEAN_RATE, MetricAttribute.M15_RATE))
-                .build(ganglia);
-
-        reporter.report(this.<Gauge>map(),
-                map("test.counter", counter),
-                this.<Histogram>map(),
-                map("test.meter", meter),
-                this.<Timer>map());
-
-        verify(ganglia).announce("m.test.counter.count", "1", GMetricType.DOUBLE, "", GMetricSlope.BOTH, 60, 0,  "test");
-        verify(ganglia).announce("m.test.meter.m1_rate", "3.0", GMetricType.DOUBLE, "events/second", GMetricSlope.BOTH, 60, 0, "test");
-        verify(ganglia).announce("m.test.meter.m5_rate", "4.0", GMetricType.DOUBLE, "events/second", GMetricSlope.BOTH, 60, 0, "test");
-        verifyNoMoreInteractions(ganglia);
-
-        reporter.close();
-    }
-
-    private <T> SortedMap<String, T> map() {
-        return new TreeMap<String, T>();
-    }
-
-    private <T> SortedMap<String, T> map(String name, T metric) {
-        final TreeMap<String, T> map = new TreeMap<String, T>();
-        map.put(name, metric);
-        return map;
-    }
-
-    private <T> Gauge gauge(T value) {
-        final Gauge gauge = mock(Gauge.class);
-        when(gauge.getValue()).thenReturn(value);
-        return gauge;
-    }
-}
diff --git a/metrics-graphite/pom.xml b/metrics-graphite/pom.xml
index 605d5e6..a0ed9e1 100644
--- a/metrics-graphite/pom.xml
+++ b/metrics-graphite/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-graphite</artifactId>
@@ -15,22 +15,80 @@
         A reporter for Metrics which announces measurements to a Graphite server.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.graphite</javaModuleName>
+        <rabbitmq.version>5.20.0</rabbitmq.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>com.rabbitmq</groupId>
             <artifactId>amqp-client</artifactId>
             <version>${rabbitmq.version}</version>
-            <optional>true</optional>
+            <exclusions>
+                <exclusion>
+                    <groupId>io.dropwizard.metrics</groupId>
+                    <artifactId>metrics-core</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>org.python</groupId>
             <artifactId>jython-standalone</artifactId>
-            <version>2.5.3</version>
+            <version>2.7.3</version>
             <scope>test</scope>
         </dependency>
     </dependencies>
diff --git a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/Graphite.java b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/Graphite.java
index 5761a7b..0baf32e 100644
--- a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/Graphite.java
+++ b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/Graphite.java
@@ -4,19 +4,23 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import javax.net.SocketFactory;
-
-import java.io.*;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
 import java.net.InetSocketAddress;
 import java.net.Socket;
 import java.net.UnknownHostException;
 import java.nio.charset.Charset;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
 /**
  * A client to a Carbon server via TCP.
  */
 public class Graphite implements GraphiteSender {
     // this may be optimistic about Carbon/Graphite
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
 
     private final String hostname;
     private final int port;
@@ -35,7 +39,7 @@ public class Graphite implements GraphiteSender {
      * {@link SocketFactory}.
      *
      * @param hostname The hostname of the Carbon server
-     * @param port The port of the Carbon server
+     * @param port     The port of the Carbon server
      */
     public Graphite(String hostname, int port) {
         this(hostname, port, SocketFactory.getDefault());
@@ -44,8 +48,8 @@ public class Graphite implements GraphiteSender {
     /**
      * Creates a new client which connects to the given address and socket factory.
      *
-     * @param hostname The hostname of the Carbon server
-     * @param port The port of the Carbon server
+     * @param hostname      The hostname of the Carbon server
+     * @param port          The port of the Carbon server
      * @param socketFactory the socket factory
      */
     public Graphite(String hostname, int port, SocketFactory socketFactory) {
@@ -56,17 +60,25 @@ public class Graphite implements GraphiteSender {
      * Creates a new client which connects to the given address and socket factory using the given
      * character set.
      *
-     * @param hostname The hostname of the Carbon server
-     * @param port The port of the Carbon server
+     * @param hostname      The hostname of the Carbon server
+     * @param port          The port of the Carbon server
      * @param socketFactory the socket factory
      * @param charset       the character set used by the server
      */
     public Graphite(String hostname, int port, SocketFactory socketFactory, Charset charset) {
+        if (hostname == null || hostname.isEmpty()) {
+            throw new IllegalArgumentException("hostname must not be null or empty");
+        }
+
+        if (port < 0 || port > 65535) {
+            throw new IllegalArgumentException("port must be a valid IP port (0-65535)");
+        }
+
         this.hostname = hostname;
         this.port = port;
         this.address = null;
-        this.socketFactory = socketFactory;
-        this.charset = charset;
+        this.socketFactory = requireNonNull(socketFactory, "socketFactory must not be null");
+        this.charset = requireNonNull(charset, "charset must not be null");
     }
 
     /**
@@ -100,9 +112,9 @@ public class Graphite implements GraphiteSender {
     public Graphite(InetSocketAddress address, SocketFactory socketFactory, Charset charset) {
         this.hostname = null;
         this.port = -1;
-        this.address = address;
-        this.socketFactory = socketFactory;
-        this.charset = charset;
+        this.address = requireNonNull(address, "address must not be null");
+        this.socketFactory = requireNonNull(socketFactory, "socketFactory must not be null");
+        this.charset = requireNonNull(charset, "charset must not be null");
     }
 
     @Override
@@ -111,16 +123,16 @@ public class Graphite implements GraphiteSender {
             throw new IllegalStateException("Already connected");
         }
         InetSocketAddress address = this.address;
-        if (address == null) {
+        // the previous dns retry logic did not work, as address.getAddress would always return the cached value
+        // this version of the simplified logic will always cause a dns request if hostname has been supplied.
+        // InetAddress.getByName forces the dns lookup
+        // if an InetSocketAddress was supplied at create time that will take precedence.
+        if (address == null || address.getHostName() == null && hostname != null) {
             address = new InetSocketAddress(hostname, port);
         }
-        if (address.getAddress() == null) {
-            // retry lookup, just in case the DNS changed
-            address = new InetSocketAddress(address.getHostName(),address.getPort());
 
-            if (address.getAddress() == null) {
-                throw new UnknownHostException(address.getHostName());
-            }
+        if (address.getAddress() == null) {
+            throw new UnknownHostException(address.getHostName());
         }
 
         this.socket = socketFactory.createSocket(address.getAddress(), address.getPort());
@@ -129,7 +141,7 @@ public class Graphite implements GraphiteSender {
 
     @Override
     public boolean isConnected() {
-    		return socket != null && socket.isConnected() && !socket.isClosed();
+        return socket != null && socket.isConnected() && !socket.isClosed();
     }
 
     @Override
diff --git a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteRabbitMQ.java b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteRabbitMQ.java
index 93e6c75..9784ef9 100644
--- a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteRabbitMQ.java
+++ b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteRabbitMQ.java
@@ -7,16 +7,15 @@ import com.rabbitmq.client.DefaultSocketConfigurator;
 
 import java.io.IOException;
 import java.net.Socket;
-import java.nio.charset.Charset;
 import java.util.concurrent.TimeoutException;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 /**
  * A rabbit-mq client to a Carbon server.
  */
 public class GraphiteRabbitMQ implements GraphiteSender {
 
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
-
     private static final Integer DEFAULT_RABBIT_CONNECTION_TIMEOUT_MS = 500;
     private static final Integer DEFAULT_RABBIT_SOCKET_TIMEOUT_MS = 5000;
     private static final Integer DEFAULT_RABBIT_REQUESTED_HEARTBEAT_SEC = 10;
@@ -91,7 +90,7 @@ public class GraphiteRabbitMQ implements GraphiteSender {
 
         this.connectionFactory = new ConnectionFactory();
 
-	connectionFactory.setSocketConfigurator(new DefaultSocketConfigurator() {
+        connectionFactory.setSocketConfigurator(new DefaultSocketConfigurator() {
             @Override
             public void configure(Socket socket) throws IOException {
                 super.configure(socket);
@@ -132,12 +131,7 @@ public class GraphiteRabbitMQ implements GraphiteSender {
             final String sanitizedName = sanitize(name);
             final String sanitizedValue = sanitize(value);
 
-            final String message =
-                    new StringBuilder()
-                            .append(sanitizedName).append(' ')
-                            .append(sanitizedValue).append(' ')
-                            .append(Long.toString(timestamp)).append('\n').toString();
-
+            final String message = sanitizedName + ' ' + sanitizedValue + ' ' + Long.toString(timestamp) + '\n';
             channel.basicPublish(exchange, sanitizedName, null, message.getBytes(UTF_8));
         } catch (IOException e) {
             failures++;
@@ -147,7 +141,7 @@ public class GraphiteRabbitMQ implements GraphiteSender {
 
     @Override
     public void flush() throws IOException {
-    	  // Nothing to do
+        // Nothing to do
     }
 
     @Override
diff --git a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteReporter.java b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteReporter.java
index f11803e..62a042c 100644
--- a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteReporter.java
+++ b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteReporter.java
@@ -1,13 +1,21 @@
 package com.codahale.metrics.graphite;
 
-import com.codahale.metrics.*;
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.Metered;
+import com.codahale.metrics.MetricAttribute;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.ScheduledReporter;
+import com.codahale.metrics.Snapshot;
 import com.codahale.metrics.Timer;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
-import java.math.BigDecimal;
-import java.math.BigInteger;
 import java.util.Collections;
 import java.util.Locale;
 import java.util.Map;
@@ -15,8 +23,23 @@ import java.util.Set;
 import java.util.SortedMap;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricAttribute.*;
+import java.util.function.DoubleFunction;
+
+import static com.codahale.metrics.MetricAttribute.COUNT;
+import static com.codahale.metrics.MetricAttribute.M15_RATE;
+import static com.codahale.metrics.MetricAttribute.M1_RATE;
+import static com.codahale.metrics.MetricAttribute.M5_RATE;
+import static com.codahale.metrics.MetricAttribute.MAX;
+import static com.codahale.metrics.MetricAttribute.MEAN;
+import static com.codahale.metrics.MetricAttribute.MEAN_RATE;
+import static com.codahale.metrics.MetricAttribute.MIN;
+import static com.codahale.metrics.MetricAttribute.P50;
+import static com.codahale.metrics.MetricAttribute.P75;
+import static com.codahale.metrics.MetricAttribute.P95;
+import static com.codahale.metrics.MetricAttribute.P98;
+import static com.codahale.metrics.MetricAttribute.P99;
+import static com.codahale.metrics.MetricAttribute.P999;
+import static com.codahale.metrics.MetricAttribute.STDDEV;
 
 /**
  * A reporter which publishes metric values to a Graphite server.
@@ -49,6 +72,8 @@ public class GraphiteReporter extends ScheduledReporter {
         private ScheduledExecutorService executor;
         private boolean shutdownExecutorOnStop;
         private Set<MetricAttribute> disabledMetricAttributes;
+        private boolean addMetricAttributesAsTags;
+        private DoubleFunction<String> floatingPointFormatter;
 
         private Builder(MetricRegistry registry) {
             this.registry = registry;
@@ -60,6 +85,8 @@ public class GraphiteReporter extends ScheduledReporter {
             this.executor = null;
             this.shutdownExecutorOnStop = true;
             this.disabledMetricAttributes = Collections.emptySet();
+            this.addMetricAttributesAsTags = false;
+            this.floatingPointFormatter = DEFAULT_FP_FORMATTER;
         }
 
         /**
@@ -147,7 +174,7 @@ public class GraphiteReporter extends ScheduledReporter {
          * Don't report the passed metric attributes for all metrics (e.g. "p999", "stddev" or "m15").
          * See {@link MetricAttribute}.
          *
-         * @param disabledMetricAttributes a {@link MetricFilter}
+         * @param disabledMetricAttributes a set of {@link MetricAttribute}
          * @return {@code this}
          */
         public Builder disabledMetricAttributes(Set<MetricAttribute> disabledMetricAttributes) {
@@ -155,10 +182,39 @@ public class GraphiteReporter extends ScheduledReporter {
             return this;
         }
 
+
+        /**
+         * Specifies whether or not metric attributes (e.g. "p999", "stddev" or "m15") should be reported in the traditional dot delimited format or in the tag based format.
+         * Without tags (default): `my.metric.p99`
+         * With tags: `my.metric;metricattribute=p99`
+         *
+         * Note that this setting only modifies the metric attribute, and will not convert any other portion of the metric name to use tags.
+         * For mor information on Graphite tag support see https://graphite.readthedocs.io/en/latest/tags.html
+         * See {@link MetricAttribute}.
+         *
+         * @param addMetricAttributesAsTags if true, then metric attributes will be added as tags
+         * @return {@code this}
+         */
+        public Builder addMetricAttributesAsTags(boolean addMetricAttributesAsTags) {
+            this.addMetricAttributesAsTags = addMetricAttributesAsTags;
+            return this;
+        }
+
+        /**
+         * Use custom floating point formatter.
+         *
+         * @param floatingPointFormatter a custom formatter for floating point values
+         * @return {@code this}
+         */
+        public Builder withFloatingPointFormatter(DoubleFunction<String> floatingPointFormatter) {
+            this.floatingPointFormatter = floatingPointFormatter;
+            return this;
+        }
+
         /**
          * Builds a {@link GraphiteReporter} with the given properties, sending metrics using the
          * given {@link GraphiteSender}.
-         *
+         * <p>
          * Present for binary compatibility
          *
          * @param graphite a {@link Graphite}
@@ -177,57 +233,135 @@ public class GraphiteReporter extends ScheduledReporter {
          */
         public GraphiteReporter build(GraphiteSender graphite) {
             return new GraphiteReporter(registry,
-                                        graphite,
-                                        clock,
-                                        prefix,
-                                        rateUnit,
-                                        durationUnit,
-                                        filter,
-                                        executor,
-                                        shutdownExecutorOnStop,
-                    disabledMetricAttributes);
+                    graphite,
+                    clock,
+                    prefix,
+                    rateUnit,
+                    durationUnit,
+                    filter,
+                    executor,
+                    shutdownExecutorOnStop,
+                    disabledMetricAttributes,
+                    addMetricAttributesAsTags,
+                    floatingPointFormatter);
         }
     }
 
     private static final Logger LOGGER = LoggerFactory.getLogger(GraphiteReporter.class);
+    // the Carbon plaintext format is pretty underspecified, but it seems like it just wants US-formatted digits
+    private static final DoubleFunction<String> DEFAULT_FP_FORMATTER = fp -> String.format(Locale.US, "%2.2f", fp);
 
     private final GraphiteSender graphite;
     private final Clock clock;
     private final String prefix;
+    private final boolean addMetricAttributesAsTags;
+    private final DoubleFunction<String> floatingPointFormatter;
+  
+  
+    /**
+     * Creates a new {@link GraphiteReporter} instance.
+     *
+     * @param registry                  the {@link MetricRegistry} containing the metrics this
+     *                                  reporter will report
+     * @param graphite                  the {@link GraphiteSender} which is responsible for sending metrics to a Carbon server
+     *                                  via a transport protocol
+     * @param clock                     the instance of the time. Use {@link Clock#defaultClock()} for the default
+     * @param prefix                    the prefix of all metric names (may be null)
+     * @param rateUnit                  the time unit of in which rates will be converted
+     * @param durationUnit              the time unit of in which durations will be converted
+     * @param filter                    the filter for which metrics to report
+     * @param executor                  the executor to use while scheduling reporting of metrics (may be null).
+     * @param shutdownExecutorOnStop    if true, then executor will be stopped in same time with this reporter
+     * @param disabledMetricAttributes  do not report specific metric attributes
+     */
+    protected GraphiteReporter(MetricRegistry registry,
+                               GraphiteSender graphite,
+                               Clock clock,
+                               String prefix,
+                               TimeUnit rateUnit,
+                               TimeUnit durationUnit,
+                               MetricFilter filter,
+                               ScheduledExecutorService executor,
+                               boolean shutdownExecutorOnStop,
+                               Set<MetricAttribute> disabledMetricAttributes) {
+        this(registry, graphite, clock, prefix, rateUnit, durationUnit, filter, executor, shutdownExecutorOnStop,
+                disabledMetricAttributes, false);
+    }
+
+
+    /**
+     * Creates a new {@link GraphiteReporter} instance.
+     *
+     * @param registry                  the {@link MetricRegistry} containing the metrics this
+     *                                  reporter will report
+     * @param graphite                  the {@link GraphiteSender} which is responsible for sending metrics to a Carbon server
+     *                                  via a transport protocol
+     * @param clock                     the instance of the time. Use {@link Clock#defaultClock()} for the default
+     * @param prefix                    the prefix of all metric names (may be null)
+     * @param rateUnit                  the time unit of in which rates will be converted
+     * @param durationUnit              the time unit of in which durations will be converted
+     * @param filter                    the filter for which metrics to report
+     * @param executor                  the executor to use while scheduling reporting of metrics (may be null).
+     * @param shutdownExecutorOnStop    if true, then executor will be stopped in same time with this reporter
+     * @param disabledMetricAttributes  do not report specific metric attributes
+     * @param addMetricAttributesAsTags if true, then add metric attributes as tags instead of suffixes
+     */
+    protected GraphiteReporter(MetricRegistry registry,
+                               GraphiteSender graphite,
+                               Clock clock,
+                               String prefix,
+                               TimeUnit rateUnit,
+                               TimeUnit durationUnit,
+                               MetricFilter filter,
+                               ScheduledExecutorService executor,
+                               boolean shutdownExecutorOnStop,
+                               Set<MetricAttribute> disabledMetricAttributes,
+                               boolean addMetricAttributesAsTags) {
+        this(registry, graphite, clock, prefix, rateUnit, durationUnit, filter, executor, shutdownExecutorOnStop,
+                disabledMetricAttributes, addMetricAttributesAsTags, DEFAULT_FP_FORMATTER);
+    }
 
     /**
      * Creates a new {@link GraphiteReporter} instance.
      *
-     * @param registry               the {@link MetricRegistry} containing the metrics this
-     *                               reporter will report
-     * @param graphite               the {@link GraphiteSender} which is responsible for sending metrics to a Carbon server
-     *                               via a transport protocol
-     * @param clock                  the instance of the time. Use {@link Clock#defaultClock()} for the default
-     * @param prefix                 the prefix of all metric names (may be null)
-     * @param rateUnit               the time unit of in which rates will be converted
-     * @param durationUnit           the time unit of in which durations will be converted
-     * @param filter                 the filter for which metrics to report
-     * @param executor               the executor to use while scheduling reporting of metrics (may be null).
-     * @param shutdownExecutorOnStop if true, then executor will be stopped in same time with this reporter
+     * @param registry                  the {@link MetricRegistry} containing the metrics this
+     *                                  reporter will report
+     * @param graphite                  the {@link GraphiteSender} which is responsible for sending metrics to a Carbon server
+     *                                  via a transport protocol
+     * @param clock                     the instance of the time. Use {@link Clock#defaultClock()} for the default
+     * @param prefix                    the prefix of all metric names (may be null)
+     * @param rateUnit                  the time unit of in which rates will be converted
+     * @param durationUnit              the time unit of in which durations will be converted
+     * @param filter                    the filter for which metrics to report
+     * @param executor                  the executor to use while scheduling reporting of metrics (may be null).
+     * @param shutdownExecutorOnStop    if true, then executor will be stopped in same time with this reporter
+     * @param disabledMetricAttributes  do not report specific metric attributes
+     * @param addMetricAttributesAsTags if true, then add metric attributes as tags instead of suffixes
+     * @param floatingPointFormatter    custom floating point formatter
      */
     protected GraphiteReporter(MetricRegistry registry,
-                             GraphiteSender graphite,
-                             Clock clock,
-                             String prefix,
-                             TimeUnit rateUnit,
-                             TimeUnit durationUnit,
-                             MetricFilter filter,
-                             ScheduledExecutorService executor,
-                             boolean shutdownExecutorOnStop,
-                             Set<MetricAttribute> disabledMetricAttributes) {
+                               GraphiteSender graphite,
+                               Clock clock,
+                               String prefix,
+                               TimeUnit rateUnit,
+                               TimeUnit durationUnit,
+                               MetricFilter filter,
+                               ScheduledExecutorService executor,
+                               boolean shutdownExecutorOnStop,
+                               Set<MetricAttribute> disabledMetricAttributes,
+                               boolean addMetricAttributesAsTags,
+                               DoubleFunction<String> floatingPointFormatter) {
         super(registry, "graphite-reporter", filter, rateUnit, durationUnit, executor, shutdownExecutorOnStop,
                 disabledMetricAttributes);
         this.graphite = graphite;
         this.clock = clock;
         this.prefix = prefix;
+        this.addMetricAttributesAsTags = addMetricAttributesAsTags;
+        this.floatingPointFormatter = floatingPointFormatter;
     }
 
     @Override
+    @SuppressWarnings("rawtypes")
     public void report(SortedMap<String, Gauge> gauges,
                        SortedMap<String, Counter> counters,
                        SortedMap<String, Histogram> histograms,
@@ -322,24 +456,24 @@ public class GraphiteReporter extends ScheduledReporter {
     }
 
     private void sendIfEnabled(MetricAttribute type, String name, double value, long timestamp) throws IOException {
-        if (getDisabledMetricAttributes().contains(type)){
+        if (getDisabledMetricAttributes().contains(type)) {
             return;
         }
-        graphite.send(prefix(name, type.getCode()), format(value), timestamp);
+        graphite.send(prefix(appendMetricAttribute(name, type.getCode())), format(value), timestamp);
     }
 
     private void sendIfEnabled(MetricAttribute type, String name, long value, long timestamp) throws IOException {
-        if (getDisabledMetricAttributes().contains(type)){
+        if (getDisabledMetricAttributes().contains(type)) {
             return;
         }
-        graphite.send(prefix(name, type.getCode()), format(value), timestamp);
+        graphite.send(prefix(appendMetricAttribute(name, type.getCode())), format(value), timestamp);
     }
 
     private void reportCounter(String name, Counter counter, long timestamp) throws IOException {
-        graphite.send(prefix(name, COUNT.getCode()), format(counter.getCount()), timestamp);
+        graphite.send(prefix(appendMetricAttribute(name, COUNT.getCode())), format(counter.getCount()), timestamp);
     }
 
-    private void reportGauge(String name, Gauge gauge, long timestamp) throws IOException {
+    private void reportGauge(String name, Gauge<?> gauge, long timestamp) throws IOException {
         final String value = format(gauge.getValue());
         if (value != null) {
             graphite.send(prefix(name), value, timestamp);
@@ -359,18 +493,23 @@ public class GraphiteReporter extends ScheduledReporter {
             return format(((Integer) o).longValue());
         } else if (o instanceof Long) {
             return format(((Long) o).longValue());
-        } else if (o instanceof BigInteger) {
-            return format(((BigInteger) o).doubleValue());
-        } else if (o instanceof BigDecimal) {
-            return format(((BigDecimal) o).doubleValue());
+        } else if (o instanceof Number) {
+            return format(((Number) o).doubleValue());
         } else if (o instanceof Boolean) {
             return format(((Boolean) o) ? 1 : 0);
         }
         return null;
     }
 
-    private String prefix(String... components) {
-        return MetricRegistry.name(prefix, components);
+    private String prefix(String name) {
+        return MetricRegistry.name(prefix, name);
+    }
+
+    private String appendMetricAttribute(String name, String metricAttribute){
+        if (addMetricAttributesAsTags){
+            return name + ";metricattribute=" + metricAttribute;
+        }
+        return name + "." + metricAttribute;
     }
 
     private String format(long n) {
@@ -378,8 +517,6 @@ public class GraphiteReporter extends ScheduledReporter {
     }
 
     protected String format(double v) {
-        // the Carbon plaintext format is pretty underspecified, but it seems like it just wants
-        // US-formatted digits
-        return String.format(Locale.US, "%2.2f", v);
+        return floatingPointFormatter.apply(v);
     }
 }
diff --git a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteSender.java b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteSender.java
index 84fe5fe..a8901c3 100644
--- a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteSender.java
+++ b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteSender.java
@@ -3,44 +3,43 @@ package com.codahale.metrics.graphite;
 import java.io.Closeable;
 import java.io.IOException;
 
-public interface GraphiteSender extends Closeable{
-
-	/**
-	 * Connects to the server.
-	 *
-	 * @throws IllegalStateException if the client is already connected
-	 * @throws IOException if there is an error connecting
-	 */
-	public void connect() throws IllegalStateException, IOException;
-
-	/**
-	 * Sends the given measurement to the server.
-	 *
-	 * @param name         the name of the metric
-	 * @param value        the value of the metric
-	 * @param timestamp    the timestamp of the metric
-	 * @throws IOException if there was an error sending the metric
-	 */
-	public void send(String name, String value, long timestamp)
-			throws IOException;
-
-	/**
-	 * Flushes buffer, if applicable
-	 *
-	 * @throws IOException
-	 */
-	void flush() throws IOException;
-
-	/**
-	 * Returns true if ready to send data
-	 */
-	boolean isConnected();
-
-	/**
-	 * Returns the number of failed writes to the server.
-	 *
-	 * @return the number of failed writes to the server
-	 */
-	public int getFailures();
+public interface GraphiteSender extends Closeable {
+
+    /**
+     * Connects to the server.
+     *
+     * @throws IllegalStateException if the client is already connected
+     * @throws IOException           if there is an error connecting
+     */
+    void connect() throws IllegalStateException, IOException;
+
+    /**
+     * Sends the given measurement to the server.
+     *
+     * @param name      the name of the metric
+     * @param value     the value of the metric
+     * @param timestamp the timestamp of the metric
+     * @throws IOException if there was an error sending the metric
+     */
+    void send(String name, String value, long timestamp) throws IOException;
+
+    /**
+     * Flushes buffer, if applicable
+     *
+     * @throws IOException if there was an error during flushing metrics to the socket
+     */
+    void flush() throws IOException;
+
+    /**
+     * Returns true if ready to send data
+     */
+    boolean isConnected();
+
+    /**
+     * Returns the number of failed writes to the server.
+     *
+     * @return the number of failed writes to the server
+     */
+    int getFailures();
 
 }
\ No newline at end of file
diff --git a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteUDP.java b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteUDP.java
index 8e44a8e..bd49426 100644
--- a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteUDP.java
+++ b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/GraphiteUDP.java
@@ -2,17 +2,17 @@ package com.codahale.metrics.graphite;
 
 import java.io.IOException;
 import java.net.InetSocketAddress;
+import java.net.InetAddress;
 import java.nio.ByteBuffer;
 import java.nio.channels.DatagramChannel;
-import java.nio.charset.Charset;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 /**
  * A client to a Carbon server using unconnected UDP
  */
 public class GraphiteUDP implements GraphiteSender {
 
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
-
     private final String hostname;
     private final int port;
     private InetSocketAddress address;
@@ -24,7 +24,7 @@ public class GraphiteUDP implements GraphiteSender {
      * Creates a new client which sends data to given address using UDP
      *
      * @param hostname The hostname of the Carbon server
-     * @param port The port of the Carbon server
+     * @param port     The port of the Carbon server
      */
     public GraphiteUDP(String hostname, int port) {
         this.hostname = hostname;
@@ -51,7 +51,7 @@ public class GraphiteUDP implements GraphiteSender {
 
         // Resolve hostname
         if (hostname != null) {
-            address = new InetSocketAddress(hostname, port);
+            address = new InetSocketAddress(InetAddress.getByName(hostname), port);
         }
 
         datagramChannel = DatagramChannel.open();
@@ -59,20 +59,13 @@ public class GraphiteUDP implements GraphiteSender {
 
     @Override
     public boolean isConnected() {
-    		return datagramChannel != null && !datagramChannel.socket().isClosed();
+        return datagramChannel != null && !datagramChannel.socket().isClosed();
     }
 
     @Override
     public void send(String name, String value, long timestamp) throws IOException {
         try {
-            StringBuilder buf = new StringBuilder();
-            buf.append(sanitize(name));
-            buf.append(' ');
-            buf.append(sanitize(value));
-            buf.append(' ');
-            buf.append(Long.toString(timestamp));
-            buf.append('\n');
-            String str = buf.toString();
+            String str = sanitize(name) + ' ' + sanitize(value) + ' ' + Long.toString(timestamp) + '\n';
             ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes(UTF_8));
             datagramChannel.send(byteBuffer, address);
             this.failures = 0;
@@ -89,7 +82,7 @@ public class GraphiteUDP implements GraphiteSender {
 
     @Override
     public void flush() throws IOException {
-    	  // Nothing to do
+        // Nothing to do
     }
 
     @Override
diff --git a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/PickledGraphite.java b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/PickledGraphite.java
index 4d8fa29..cc43fed 100644
--- a/metrics-graphite/src/main/java/com/codahale/metrics/graphite/PickledGraphite.java
+++ b/metrics-graphite/src/main/java/com/codahale/metrics/graphite/PickledGraphite.java
@@ -16,22 +16,48 @@ import java.net.Socket;
 import java.net.UnknownHostException;
 import java.nio.ByteBuffer;
 import java.nio.charset.Charset;
-import java.util.LinkedList;
+import java.util.ArrayList;
 import java.util.List;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 /**
  * A client to a Carbon server that sends all metrics after they have been pickled in configurable sized batches
  */
 public class PickledGraphite implements GraphiteSender {
 
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
+    static class MetricTuple {
+        String name;
+        long timestamp;
+        String value;
+
+        MetricTuple(String name, long timestamp, String value) {
+            this.name = name;
+            this.timestamp = timestamp;
+            this.value = value;
+        }
+    }
+
+    /**
+     * Minimally necessary pickle opcodes.
+     */
+    private static final char
+            MARK = '(',
+            STOP = '.',
+            LONG = 'L',
+            STRING = 'S',
+            APPEND = 'a',
+            LIST = 'l',
+            TUPLE = 't',
+            QUOTE = '\'',
+            LF = '\n';
 
     private static final Logger LOGGER = LoggerFactory.getLogger(PickledGraphite.class);
     private final static int DEFAULT_BATCH_SIZE = 100;
 
     private int batchSize;
     // graphite expects a python-pickled list of nested tuples.
-    private List<MetricTuple> metrics = new LinkedList<MetricTuple>();
+    private List<MetricTuple> metrics = new ArrayList<>();
 
     private final String hostname;
     private final int port;
@@ -47,8 +73,7 @@ public class PickledGraphite implements GraphiteSender {
      * Creates a new client which connects to the given address using the default {@link SocketFactory}. This defaults
      * to a batchSize of 100
      *
-     * @param address
-     *            the address of the Carbon server
+     * @param address the address of the Carbon server
      */
     public PickledGraphite(InetSocketAddress address) {
         this(address, DEFAULT_BATCH_SIZE);
@@ -57,10 +82,8 @@ public class PickledGraphite implements GraphiteSender {
     /**
      * Creates a new client which connects to the given address using the default {@link SocketFactory}.
      *
-     * @param address
-     *            the address of the Carbon server
-     * @param batchSize
-     *            how many metrics are bundled into a single pickle request to graphite
+     * @param address   the address of the Carbon server
+     * @param batchSize how many metrics are bundled into a single pickle request to graphite
      */
     public PickledGraphite(InetSocketAddress address, int batchSize) {
         this(address, SocketFactory.getDefault(), batchSize);
@@ -69,12 +92,9 @@ public class PickledGraphite implements GraphiteSender {
     /**
      * Creates a new client which connects to the given address and socket factory.
      *
-     * @param address
-     *            the address of the Carbon server
-     * @param socketFactory
-     *            the socket factory
-     * @param batchSize
-     *            how many metrics are bundled into a single pickle request to graphite
+     * @param address       the address of the Carbon server
+     * @param socketFactory the socket factory
+     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
      */
     public PickledGraphite(InetSocketAddress address, SocketFactory socketFactory, int batchSize) {
         this(address, socketFactory, UTF_8, batchSize);
@@ -83,14 +103,10 @@ public class PickledGraphite implements GraphiteSender {
     /**
      * Creates a new client which connects to the given address and socket factory using the given character set.
      *
-     * @param address
-     *            the address of the Carbon server
-     * @param socketFactory
-     *            the socket factory
-     * @param charset
-     *            the character set used by the server
-     * @param batchSize
-     *            how many metrics are bundled into a single pickle request to graphite
+     * @param address       the address of the Carbon server
+     * @param socketFactory the socket factory
+     * @param charset       the character set used by the server
+     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
      */
     public PickledGraphite(InetSocketAddress address, SocketFactory socketFactory, Charset charset, int batchSize) {
         this.address = address;
@@ -105,10 +121,8 @@ public class PickledGraphite implements GraphiteSender {
      * Creates a new client which connects to the given address using the default {@link SocketFactory}. This defaults
      * to a batchSize of 100
      *
-     * @param hostname
-     *            the hostname of the Carbon server
-     * @param port
-     *            the port of the Carbon server
+     * @param hostname the hostname of the Carbon server
+     * @param port     the port of the Carbon server
      */
     public PickledGraphite(String hostname, int port) {
         this(hostname, port, DEFAULT_BATCH_SIZE);
@@ -117,12 +131,9 @@ public class PickledGraphite implements GraphiteSender {
     /**
      * Creates a new client which connects to the given address using the default {@link SocketFactory}.
      *
-     * @param hostname
-     *            the hostname of the Carbon server
-     * @param port
-     *            the port of the Carbon server
-     * @param batchSize
-     *            how many metrics are bundled into a single pickle request to graphite
+     * @param hostname  the hostname of the Carbon server
+     * @param port      the port of the Carbon server
+     * @param batchSize how many metrics are bundled into a single pickle request to graphite
      */
     public PickledGraphite(String hostname, int port, int batchSize) {
         this(hostname, port, SocketFactory.getDefault(), batchSize);
@@ -131,14 +142,10 @@ public class PickledGraphite implements GraphiteSender {
     /**
      * Creates a new client which connects to the given address and socket factory.
      *
-     * @param hostname
-     *            the hostname of the Carbon server
-     * @param port
-     *            the port of the Carbon server
-     * @param socketFactory
-     *            the socket factory
-     * @param batchSize
-     *            how many metrics are bundled into a single pickle request to graphite
+     * @param hostname      the hostname of the Carbon server
+     * @param port          the port of the Carbon server
+     * @param socketFactory the socket factory
+     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
      */
     public PickledGraphite(String hostname, int port, SocketFactory socketFactory, int batchSize) {
         this(hostname, port, socketFactory, UTF_8, batchSize);
@@ -147,16 +154,11 @@ public class PickledGraphite implements GraphiteSender {
     /**
      * Creates a new client which connects to the given address and socket factory using the given character set.
      *
-     * @param hostname
-     *            the hostname of the Carbon server
-     * @param port
-     *            the port of the Carbon server
-     * @param socketFactory
-     *            the socket factory
-     * @param charset
-     *            the character set used by the server
-     * @param batchSize
-     *            how many metrics are bundled into a single pickle request to graphite
+     * @param hostname      the hostname of the Carbon server
+     * @param port          the port of the Carbon server
+     * @param socketFactory the socket factory
+     * @param charset       the character set used by the server
+     * @param batchSize     how many metrics are bundled into a single pickle request to graphite
      */
     public PickledGraphite(String hostname, int port, SocketFactory socketFactory, Charset charset, int batchSize) {
         this.address = null;
@@ -191,19 +193,15 @@ public class PickledGraphite implements GraphiteSender {
 
     /**
      * Convert the metric to a python tuple of the form:
-     * <p/>
+     * <p>
      * (timestamp, (name, value))
-     * <p/>
+     * <p>
      * And add it to the list of metrics. If we reach the batch size, write them out.
      *
-     * @param name
-     *            the name of the metric
-     * @param value
-     *            the value of the metric
-     * @param timestamp
-     *            the timestamp of the metric
-     * @throws IOException
-     *             if there was an error sending the metric
+     * @param name      the name of the metric
+     * @param value     the value of the metric
+     * @param timestamp the timestamp of the metric
+     * @throws IOException if there was an error sending the metric
      */
     @Override
     public void send(String name, String value, long timestamp) throws IOException {
@@ -276,27 +274,14 @@ public class PickledGraphite implements GraphiteSender {
         }
     }
 
-    /**
-     * Minimally necessary pickle opcodes.
-     */
-    private final char
-            MARK = '(',
-            STOP = '.',
-            LONG = 'L',
-            STRING = 'S',
-            APPEND = 'a',
-            LIST = 'l',
-            TUPLE = 't',
-            QUOTE = '\'',
-            LF = '\n';
-
     /**
      * See: http://readthedocs.org/docs/graphite/en/1.0/feeding-carbon.html
      *
-     * @throws IOException
+     * @throws IOException shouldn't happen because we write to memory.
      */
     byte[] pickleMetrics(List<MetricTuple> metrics) throws IOException {
-        ByteArrayOutputStream out = new ByteArrayOutputStream(metrics.size() * 75); // Extremely rough estimate of 75 bytes per message
+        // Extremely rough estimate of 75 bytes per message
+        ByteArrayOutputStream out = new ByteArrayOutputStream(metrics.size() * 75);
         Writer pickled = new OutputStreamWriter(out, charset);
 
         pickled.append(MARK);
@@ -345,18 +330,6 @@ public class PickledGraphite implements GraphiteSender {
         return out.toByteArray();
     }
 
-    static class MetricTuple {
-        String name;
-        long timestamp;
-        String value;
-
-        MetricTuple(String name, long timestamp, String value) {
-            this.name = name;
-            this.timestamp = timestamp;
-            this.value = value;
-        }
-    }
-
     protected String sanitize(String s) {
         return GraphiteSanitize.sanitize(s);
     }
diff --git a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteRabbitMQTest.java b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteRabbitMQTest.java
index 0ac5c16..3b665b7 100755
--- a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteRabbitMQTest.java
+++ b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteRabbitMQTest.java
@@ -1,6 +1,5 @@
 package com.codahale.metrics.graphite;
 
-import com.rabbitmq.client.AMQP;
 import com.rabbitmq.client.Channel;
 import com.rabbitmq.client.Connection;
 import com.rabbitmq.client.ConnectionFactory;
@@ -9,16 +8,20 @@ import org.junit.Test;
 
 import java.io.IOException;
 import java.net.UnknownHostException;
-import java.nio.charset.Charset;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Fail.failBecauseExceptionWasNotThrown;
-
-
-import static org.mockito.Mockito.*;
-
-public class GraphiteRabbitMQTest
-{
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.atMost;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class GraphiteRabbitMQTest {
     private final ConnectionFactory connectionFactory = mock(ConnectionFactory.class);
     private final Connection connection = mock(Connection.class);
     private final Channel channel = mock(Channel.class);
@@ -29,8 +32,6 @@ public class GraphiteRabbitMQTest
 
     private final GraphiteRabbitMQ graphite = new GraphiteRabbitMQ(connectionFactory, "graphite");
 
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
-
     @Before
     public void setUp() throws Exception {
         when(connectionFactory.newConnection()).thenReturn(connection);
@@ -40,8 +41,8 @@ public class GraphiteRabbitMQTest
         when(bogusConnectionFactory.newConnection()).thenReturn(bogusConnection);
         when(bogusConnection.createChannel()).thenReturn(bogusChannel);
         doThrow(new IOException())
-                .when(bogusChannel)
-                .basicPublish(anyString(), anyString(), any(AMQP.BasicProperties.class), any(byte[].class));
+            .when(bogusChannel)
+            .basicPublish(anyString(), anyString(), any(), any(byte[].class));
     }
 
     @Test
@@ -55,14 +56,14 @@ public class GraphiteRabbitMQTest
 
     @Test
     public void measuresFailures() throws Exception {
-        final GraphiteRabbitMQ graphite = new GraphiteRabbitMQ(bogusConnectionFactory, "graphite");
-        graphite.connect();
-
-        try {
-            graphite.send("name", "value", 0);
-            failBecauseExceptionWasNotThrown(IOException.class);
-        } catch (IOException e) {
-            assertThat(graphite.getFailures()).isEqualTo(1);
+        try (final GraphiteRabbitMQ graphite = new GraphiteRabbitMQ(bogusConnectionFactory, "graphite")) {
+            graphite.connect();
+            try {
+                graphite.send("name", "value", 0);
+                failBecauseExceptionWasNotThrown(IOException.class);
+            } catch (IOException e) {
+                assertThat(graphite.getFailures()).isEqualTo(1);
+            }
         }
     }
 
@@ -82,7 +83,7 @@ public class GraphiteRabbitMQTest
             failBecauseExceptionWasNotThrown(IllegalStateException.class);
         } catch (IllegalStateException e) {
             assertThat(e.getMessage()).isEqualTo("Already connected");
-       }
+        }
     }
 
     @Test
@@ -92,7 +93,8 @@ public class GraphiteRabbitMQTest
 
         String expectedMessage = "name value 100\n";
 
-        verify(channel, times(1)).basicPublish("graphite", "name", null, expectedMessage.getBytes(UTF_8));
+        verify(channel, times(1)).basicPublish("graphite", "name", null,
+            expectedMessage.getBytes(UTF_8));
 
         assertThat(graphite.getFailures()).isZero();
     }
@@ -104,24 +106,22 @@ public class GraphiteRabbitMQTest
 
         String expectedMessage = "name-to-sanitize value-to-sanitize 100\n";
 
-        verify(channel, times(1)).basicPublish("graphite", "name-to-sanitize", null, expectedMessage.getBytes(UTF_8));
+        verify(channel, times(1)).basicPublish("graphite", "name-to-sanitize", null,
+            expectedMessage.getBytes(UTF_8));
 
         assertThat(graphite.getFailures()).isZero();
     }
 
     @Test
-    public void shouldFailWhenGraphiteHostUnavailable() throws Exception {
+    public void shouldFailWhenGraphiteHostUnavailable() {
         ConnectionFactory connectionFactory = new ConnectionFactory();
         connectionFactory.setHost("some-unknown-host");
 
-        GraphiteRabbitMQ unavailableGraphite = new GraphiteRabbitMQ(connectionFactory, "graphite");
-
-        try {
+        try (GraphiteRabbitMQ unavailableGraphite = new GraphiteRabbitMQ(connectionFactory, "graphite")) {
             unavailableGraphite.connect();
             failBecauseExceptionWasNotThrown(UnknownHostException.class);
         } catch (Exception e) {
-            assertThat(e.getMessage())
-                    .isEqualTo("some-unknown-host");
+            assertThat(e.getMessage()).contains("some-unknown-host");
         }
     }
 }
diff --git a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteReporterTest.java b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteReporterTest.java
index a4a43f5..3498b2c 100644
--- a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteReporterTest.java
+++ b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteReporterTest.java
@@ -1,21 +1,37 @@
 package com.codahale.metrics.graphite;
 
-import com.codahale.metrics.*;
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricAttribute;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Snapshot;
 import com.codahale.metrics.Timer;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.InOrder;
 
 import java.net.UnknownHostException;
-import java.util.Locale;
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.Locale;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
 
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
 
 public class GraphiteReporterTest {
     private final long timestamp = 1000198;
@@ -23,36 +39,36 @@ public class GraphiteReporterTest {
     private final Graphite graphite = mock(Graphite.class);
     private final MetricRegistry registry = mock(MetricRegistry.class);
     private final GraphiteReporter reporter = GraphiteReporter.forRegistry(registry)
-                                                              .withClock(clock)
-                                                              .prefixedWith("prefix")
-                                                              .convertRatesTo(TimeUnit.SECONDS)
-                                                              .convertDurationsTo(TimeUnit.MILLISECONDS)
-                                                              .filter(MetricFilter.ALL)
-                                                              .disabledMetricAttributes(Collections.<MetricAttribute>emptySet())
-                                                              .build(graphite);
+        .withClock(clock)
+        .prefixedWith("prefix")
+        .convertRatesTo(TimeUnit.SECONDS)
+        .convertDurationsTo(TimeUnit.MILLISECONDS)
+        .filter(MetricFilter.ALL)
+        .disabledMetricAttributes(Collections.emptySet())
+        .build(graphite);
 
     private final GraphiteReporter minuteRateReporter = GraphiteReporter
-            .forRegistry(registry)
-            .withClock(clock)
-            .prefixedWith("prefix")
-            .convertRatesTo(TimeUnit.MINUTES)
-            .convertDurationsTo(TimeUnit.MILLISECONDS)
-            .filter(MetricFilter.ALL)
-            .disabledMetricAttributes(Collections.<MetricAttribute>emptySet())
-            .build(graphite);
+        .forRegistry(registry)
+        .withClock(clock)
+        .prefixedWith("prefix")
+        .convertRatesTo(TimeUnit.MINUTES)
+        .convertDurationsTo(TimeUnit.MILLISECONDS)
+        .filter(MetricFilter.ALL)
+        .disabledMetricAttributes(Collections.emptySet())
+        .build(graphite);
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         when(clock.getTime()).thenReturn(timestamp * 1000);
     }
 
     @Test
     public void doesNotReportStringGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge("value")),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -66,10 +82,10 @@ public class GraphiteReporterTest {
     @Test
     public void reportsByteGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge((byte) 1)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -83,10 +99,10 @@ public class GraphiteReporterTest {
     @Test
     public void reportsShortGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge((short) 1)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -100,10 +116,10 @@ public class GraphiteReporterTest {
     @Test
     public void reportsIntegerGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge(1)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -117,10 +133,10 @@ public class GraphiteReporterTest {
     @Test
     public void reportsLongGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge(1L)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -134,10 +150,10 @@ public class GraphiteReporterTest {
     @Test
     public void reportsFloatGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge(1.1f)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -151,10 +167,10 @@ public class GraphiteReporterTest {
     @Test
     public void reportsDoubleGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge(1.1)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -167,42 +183,61 @@ public class GraphiteReporterTest {
 
     @Test
     public void reportsDoubleGaugeValuesWithCustomFormat() throws Exception {
-        final GraphiteReporter graphiteReporter = new GraphiteReporter(registry, graphite, clock, "prefix",
-                TimeUnit.SECONDS, TimeUnit.MICROSECONDS, MetricFilter.ALL, null, false,
-                Collections.<MetricAttribute>emptySet()){
-            @Override
-            protected String format(double v) {
-                return String.format(Locale.US, "%4.4f", v);
-            }
-        };
-        graphiteReporter.report(map("gauge", gauge(1.13574)),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map(),
-                this.<Timer>map());
+        try (final GraphiteReporter graphiteReporter = getReporterWithCustomFormat()) {
+            reportGaugeValue(graphiteReporter, 1.13574);
+            verifyGraphiteSentCorrectMetricValue("prefix.gauge", "1.1357", timestamp);
+            verifyNoMoreInteractions(graphite);
+        }
+    }
+
+    @Test
+    public void reportDoubleGaugeValuesUsingCustomFormatter() throws Exception {
+        DecimalFormat formatter = new DecimalFormat("##.##########", DecimalFormatSymbols.getInstance(Locale.US));
+
+        try (GraphiteReporter graphiteReporter = GraphiteReporter.forRegistry(registry)
+                .withClock(clock)
+                .prefixedWith("prefix")
+                .convertRatesTo(TimeUnit.SECONDS)
+                .convertDurationsTo(TimeUnit.MILLISECONDS)
+                .filter(MetricFilter.ALL)
+                .disabledMetricAttributes(Collections.emptySet())
+                .withFloatingPointFormatter(formatter::format)
+                .build(graphite)) {
+            reportGaugeValue(graphiteReporter, 0.000045322);
+            verifyGraphiteSentCorrectMetricValue("prefix.gauge", "0.000045322", timestamp);
+            verifyNoMoreInteractions(graphite);
+        }
+    }
 
+    private void reportGaugeValue(GraphiteReporter graphiteReporter, double value) {
+        graphiteReporter.report(map("gauge", gauge(value)),
+                map(),
+                map(),
+                map(),
+                map());
+    }
+
+    private void verifyGraphiteSentCorrectMetricValue(String metricName, String value, long timestamp) throws Exception {
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
-        inOrder.verify(graphite).send("prefix.gauge", "1.1357", timestamp);
+        inOrder.verify(graphite).send(metricName, value, timestamp);
         inOrder.verify(graphite).flush();
         inOrder.verify(graphite).close();
-
-        verifyNoMoreInteractions(graphite);
     }
 
     @Test
     public void reportsBooleanGaugeValues() throws Exception {
         reporter.report(map("gauge", gauge(true)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         reporter.report(map("gauge", gauge(false)),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
         inOrder.verify(graphite).send("prefix.gauge", "1", timestamp);
@@ -221,11 +256,11 @@ public class GraphiteReporterTest {
         final Counter counter = mock(Counter.class);
         when(counter.getCount()).thenReturn(100L);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map("counter", counter),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+        reporter.report(map(),
+            map("counter", counter),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -255,11 +290,11 @@ public class GraphiteReporterTest {
 
         when(histogram.getSnapshot()).thenReturn(snapshot);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map("histogram", histogram),
-                        this.<Meter>map(),
-                        this.<Timer>map());
+        reporter.report(map(),
+            map(),
+            map("histogram", histogram),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -289,11 +324,11 @@ public class GraphiteReporterTest {
         when(meter.getFifteenMinuteRate()).thenReturn(4.0);
         when(meter.getMeanRate()).thenReturn(5.0);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map("meter", meter),
-                        this.<Timer>map());
+        reporter.report(map(),
+            map(),
+            map(),
+            map("meter", meter),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -317,11 +352,11 @@ public class GraphiteReporterTest {
         when(meter.getFifteenMinuteRate()).thenReturn(4.0);
         when(meter.getMeanRate()).thenReturn(5.0);
 
-        minuteRateReporter.report(this.<Gauge>map(),
-                this.<Counter>map(),
-                this.<Histogram>map(),
-                this.<Meter>map("meter", meter),
-                this.<Timer>map());
+        minuteRateReporter.report(this.map(),
+            this.map(),
+            this.map(),
+            this.map("meter", meter),
+            this.map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -356,15 +391,15 @@ public class GraphiteReporterTest {
         when(snapshot.get98thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(800));
         when(snapshot.get99thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS.toNanos(900));
         when(snapshot.get999thPercentile()).thenReturn((double) TimeUnit.MILLISECONDS
-                                                                        .toNanos(1000));
+            .toNanos(1000));
 
         when(timer.getSnapshot()).thenReturn(snapshot);
 
-        reporter.report(this.<Gauge>map(),
-                        this.<Counter>map(),
-                        this.<Histogram>map(),
-                        this.<Meter>map(),
-                        map("timer", timer));
+        reporter.report(map(),
+            map(),
+            map(),
+            map(),
+            map("timer", timer));
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -395,10 +430,10 @@ public class GraphiteReporterTest {
     public void closesConnectionIfGraphiteIsUnavailable() throws Exception {
         doThrow(new UnknownHostException("UNKNOWN-HOST")).when(graphite).connect();
         reporter.report(map("gauge", gauge(1)),
-            this.<Counter>map(),
-            this.<Histogram>map(),
-            this.<Meter>map(),
-            this.<Timer>map());
+            map(),
+            map(),
+            map(),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -412,7 +447,10 @@ public class GraphiteReporterTest {
     public void closesConnectionOnReporterStop() throws Exception {
         reporter.stop();
 
-        verify(graphite).close();
+        final InOrder inOrder = inOrder(graphite);
+        inOrder.verify(graphite).connect();
+        inOrder.verify(graphite).flush();
+        inOrder.verify(graphite, times(2)).close();
 
         verifyNoMoreInteractions(graphite);
     }
@@ -431,18 +469,18 @@ public class GraphiteReporterTest {
 
         Set<MetricAttribute> disabledMetricAttributes = EnumSet.of(MetricAttribute.M15_RATE, MetricAttribute.M5_RATE);
         GraphiteReporter reporterWithdisabledMetricAttributes = GraphiteReporter.forRegistry(registry)
-                .withClock(clock)
-                .prefixedWith("prefix")
-                .convertRatesTo(TimeUnit.SECONDS)
-                .convertDurationsTo(TimeUnit.MILLISECONDS)
-                .filter(MetricFilter.ALL)
-                .disabledMetricAttributes(disabledMetricAttributes)
-                .build(graphite);
-        reporterWithdisabledMetricAttributes.report(this.<Gauge>map(),
-                this.<Counter>map("counter", counter),
-                this.<Histogram>map(),
-                this.<Meter>map("meter", meter),
-                this.<Timer>map());
+            .withClock(clock)
+            .prefixedWith("prefix")
+            .convertRatesTo(TimeUnit.SECONDS)
+            .convertDurationsTo(TimeUnit.MILLISECONDS)
+            .filter(MetricFilter.ALL)
+            .disabledMetricAttributes(disabledMetricAttributes)
+            .build(graphite);
+        reporterWithdisabledMetricAttributes.report(map(),
+            map("counter", counter),
+            map(),
+            map("meter", meter),
+            map());
 
         final InOrder inOrder = inOrder(graphite);
         inOrder.verify(graphite).connect();
@@ -456,20 +494,61 @@ public class GraphiteReporterTest {
         verifyNoMoreInteractions(graphite);
     }
 
+    @Test
+    public void sendsMetricAttributesAsTagsIfEnabled() throws Exception {
+        final Counter counter = mock(Counter.class);
+        when(counter.getCount()).thenReturn(100L);
+
+        getReporterThatSendsMetricAttributesAsTags().report(map(),
+                map("counter", counter),
+                map(),
+                map(),
+                map());
+
+        final InOrder inOrder = inOrder(graphite);
+        inOrder.verify(graphite).connect();
+        inOrder.verify(graphite).send("prefix.counter;metricattribute=count", "100", timestamp);
+        inOrder.verify(graphite).flush();
+        inOrder.verify(graphite).close();
+
+        verifyNoMoreInteractions(graphite);
+    }
+
+    private GraphiteReporter getReporterWithCustomFormat() {
+        return new GraphiteReporter(registry, graphite, clock, "prefix",
+            TimeUnit.SECONDS, TimeUnit.MICROSECONDS, MetricFilter.ALL, null, false,
+            Collections.emptySet(), false) {
+            @Override
+            protected String format(double v) {
+                return String.format(Locale.US, "%4.4f", v);
+            }
+        };
+    }
+
+
+    private GraphiteReporter getReporterThatSendsMetricAttributesAsTags() {
+        return GraphiteReporter.forRegistry(registry)
+                .withClock(clock)
+                .prefixedWith("prefix")
+                .convertRatesTo(TimeUnit.SECONDS)
+                .convertDurationsTo(TimeUnit.MILLISECONDS)
+                .filter(MetricFilter.ALL)
+                .disabledMetricAttributes(Collections.emptySet())
+                .addMetricAttributesAsTags(true)
+                .build(graphite);
+    }
 
     private <T> SortedMap<String, T> map() {
-        return new TreeMap<String, T>();
+        return new TreeMap<>();
     }
 
     private <T> SortedMap<String, T> map(String name, T metric) {
-        final TreeMap<String, T> map = new TreeMap<String, T>();
+        final TreeMap<String, T> map = new TreeMap<>();
         map.put(name, metric);
         return map;
     }
 
-    private <T> Gauge gauge(T value) {
-        final Gauge gauge = mock(Gauge.class);
-        when(gauge.getValue()).thenReturn(value);
-        return gauge;
+    private <T> Gauge<T> gauge(T value) {
+        return () -> value;
     }
 }
diff --git a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteTest.java b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteTest.java
index b3337ce..794ca74 100644
--- a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteTest.java
+++ b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/GraphiteTest.java
@@ -2,12 +2,10 @@ package com.codahale.metrics.graphite;
 
 import org.junit.Before;
 import org.junit.Test;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.stubbing.Answer;
 
 import javax.net.SocketFactory;
-
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.Socket;
@@ -15,10 +13,16 @@ import java.net.UnknownHostException;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Fail.failBecauseExceptionWasNotThrown;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Mockito.*;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 public class GraphiteTest {
     private final String host = "example.com";
@@ -27,48 +31,30 @@ public class GraphiteTest {
     private final InetSocketAddress address = new InetSocketAddress(host, port);
 
     private final Socket socket = mock(Socket.class);
-    private final ByteArrayOutputStream output = spy(new ByteArrayOutputStream());
-
-    private Graphite graphite;
+    private final ByteArrayOutputStream output = spy(ByteArrayOutputStream.class);
 
     @Before
     public void setUp() throws Exception {
         final AtomicBoolean connected = new AtomicBoolean(true);
         final AtomicBoolean closed = new AtomicBoolean(false);
 
-        when(socket.isConnected()).thenAnswer(new Answer<Boolean>() {
-            @Override
-            public Boolean answer(InvocationOnMock invocation) throws Throwable {
-                return connected.get();
-            }
-        });
-
-        when(socket.isClosed()).thenAnswer(new Answer<Boolean>() {
-            @Override
-            public Boolean answer(InvocationOnMock invocation) throws Throwable {
-                return closed.get();
-            }
-        });
-
-        doAnswer(new Answer<Void>() {
-            @Override
-            public Void answer(InvocationOnMock invocation) throws Throwable {
-                connected.set(false);
-                closed.set(true);
-                return null;
-            }
+        when(socket.isConnected()).thenAnswer(invocation -> connected.get());
+
+        when(socket.isClosed()).thenAnswer(invocation -> closed.get());
+
+        doAnswer(invocation -> {
+            connected.set(false);
+            closed.set(true);
+            return null;
         }).when(socket).close();
 
         when(socket.getOutputStream()).thenReturn(output);
 
         // Mock behavior of socket.getOutputStream().close() calling socket.close();
-        doAnswer(new Answer<Void>() {
-            @Override
-            public Void answer(InvocationOnMock invocation) throws Throwable {
-                invocation.callRealMethod();
-                socket.close();
-                return null;
-            }
+        doAnswer(invocation -> {
+            invocation.callRealMethod();
+            socket.close();
+            return null;
         }).when(output).close();
 
         when(socketFactory.createSocket(any(InetAddress.class), anyInt())).thenReturn(socket);
@@ -76,94 +62,82 @@ public class GraphiteTest {
 
     @Test
     public void connectsToGraphiteWithInetSocketAddress() throws Exception {
-        graphite = new Graphite(address, socketFactory);
-        graphite.connect();
-
+        try (Graphite graphite = new Graphite(address, socketFactory)) {
+            graphite.connect();
+        }
         verify(socketFactory).createSocket(address.getAddress(), address.getPort());
     }
 
     @Test
     public void connectsToGraphiteWithHostAndPort() throws Exception {
-        graphite = new Graphite(host, port, socketFactory);
-        graphite.connect();
-
+        try (Graphite graphite = new Graphite(host, port, socketFactory)) {
+            graphite.connect();
+        }
         verify(socketFactory).createSocket(address.getAddress(), port);
     }
 
     @Test
-    public void measuresFailures() throws Exception {
-        graphite = new Graphite(address, socketFactory);
-        assertThat(graphite.getFailures())
-                .isZero();
+    public void measuresFailures() throws IOException {
+        try (Graphite graphite = new Graphite(address, socketFactory)) {
+            assertThat(graphite.getFailures()).isZero();
+        }
     }
 
     @Test
     public void disconnectsFromGraphite() throws Exception {
-        graphite = new Graphite(address, socketFactory);
-        graphite.connect();
-        graphite.close();
+        try (Graphite graphite = new Graphite(address, socketFactory)) {
+            graphite.connect();
+        }
 
         verify(socket, times(2)).close();
     }
 
     @Test
     public void doesNotAllowDoubleConnections() throws Exception {
-        graphite = new Graphite(address, socketFactory);
-        graphite.connect();
-        try {
-            graphite.connect();
-            failBecauseExceptionWasNotThrown(IllegalStateException.class);
-        } catch (IllegalStateException e) {
-            assertThat(e.getMessage())
-                    .isEqualTo("Already connected");
+        try (Graphite graphite = new Graphite(address, socketFactory)) {
+            assertThatNoException().isThrownBy(graphite::connect);
+            assertThatThrownBy(graphite::connect)
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessage("Already connected");
         }
     }
 
     @Test
     public void writesValuesToGraphite() throws Exception {
-        graphite = new Graphite(address, socketFactory);
-        graphite.connect();
-        graphite.send("name", "value", 100);
-        graphite.close();
-
-        assertThat(output.toString())
-                .isEqualTo("name value 100\n");
+        try (Graphite graphite = new Graphite(address, socketFactory)) {
+            graphite.connect();
+            graphite.send("name", "value", 100);
+        }
+        assertThat(output).hasToString("name value 100\n");
     }
 
     @Test
     public void sanitizesNames() throws Exception {
-        graphite = new Graphite(address, socketFactory);
-        graphite.connect();
-        graphite.send("name woo", "value", 100);
-        graphite.close();
-
-        assertThat(output.toString())
-                .isEqualTo("name-woo value 100\n");
+        try (Graphite graphite = new Graphite(address, socketFactory)) {
+            graphite.connect();
+            graphite.send("name woo", "value", 100);
+        }
+        assertThat(output).hasToString("name-woo value 100\n");
     }
 
     @Test
     public void sanitizesValues() throws Exception {
-        graphite = new Graphite(address, socketFactory);
-        graphite.connect();
-        graphite.send("name", "value woo", 100);
-        graphite.close();
-
-        assertThat(output.toString())
-                .isEqualTo("name value-woo 100\n");
+        try (Graphite graphite = new Graphite(address, socketFactory)) {
+            graphite.connect();
+            graphite.send("name", "value woo", 100);
+        }
+        assertThat(output).hasToString("name value-woo 100\n");
     }
 
     @Test
-    public void notifiesIfGraphiteIsUnavailable() throws Exception {
+    public void notifiesIfGraphiteIsUnavailable() throws IOException {
         final String unavailableHost = "unknown-host-10el6m7yg56ge7dmcom";
         InetSocketAddress unavailableAddress = new InetSocketAddress(unavailableHost, 1234);
-        Graphite unavailableGraphite = new Graphite(unavailableAddress, socketFactory);
-
-        try {
-            unavailableGraphite.connect();
-            failBecauseExceptionWasNotThrown(UnknownHostException.class);
-        } catch (Exception e) {
-            assertThat(e.getMessage())
-                .isEqualTo(unavailableHost);
+
+        try (Graphite unavailableGraphite = new Graphite(unavailableAddress, socketFactory)) {
+            assertThatThrownBy(unavailableGraphite::connect)
+                    .isInstanceOf(UnknownHostException.class)
+                    .hasMessage(unavailableHost);
         }
     }
 }
diff --git a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/PickledGraphiteTest.java b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/PickledGraphiteTest.java
index ddee924..c08a793 100644
--- a/metrics-graphite/src/test/java/com/codahale/metrics/graphite/PickledGraphiteTest.java
+++ b/metrics-graphite/src/test/java/com/codahale/metrics/graphite/PickledGraphiteTest.java
@@ -1,15 +1,7 @@
 package com.codahale.metrics.graphite;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Fail.failBecauseExceptionWasNotThrown;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Mockito.*;
-
 import org.junit.Before;
 import org.junit.Test;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.stubbing.Answer;
 import org.python.core.PyList;
 import org.python.core.PyTuple;
 
@@ -20,32 +12,33 @@ import javax.script.CompiledScript;
 import javax.script.ScriptEngine;
 import javax.script.ScriptEngineManager;
 import javax.script.SimpleBindings;
-
 import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.math.BigInteger;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.Socket;
-import java.nio.charset.Charset;
 import java.util.concurrent.atomic.AtomicBoolean;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Fail.failBecauseExceptionWasNotThrown;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
 public class PickledGraphiteTest {
     private final SocketFactory socketFactory = mock(SocketFactory.class);
     private final InetSocketAddress address = new InetSocketAddress("example.com", 1234);
-    private final PickledGraphite graphite = new PickledGraphite(address, socketFactory, Charset.forName("UTF-8"), 2);
+    private final PickledGraphite graphite = new PickledGraphite(address, socketFactory, UTF_8, 2);
 
     private final Socket socket = mock(Socket.class);
-    private final ByteArrayOutputStream output = spy(new ByteArrayOutputStream());
-
-    // Pulls apart the pickled payload. This skips ahead 4 characters to safely ignore
-    // the header (length)
-    private static final String UNPICKLER_SCRIPT =
-        "import cPickle\n" +
-            "import struct\n" +
-            "format = '!L'\n" +
-            "headerLength = struct.calcsize(format)\n" +
-            "payloadLength, = struct.unpack(format, payload[:headerLength])\n" +
-            "batchLength = headerLength + payloadLength.intValue()\n" +
-            "metrics = cPickle.loads(payload[headerLength:batchLength])\n";
+    private final ByteArrayOutputStream output = spy(ByteArrayOutputStream.class);
 
     private CompiledScript unpickleScript;
 
@@ -54,39 +47,23 @@ public class PickledGraphiteTest {
         final AtomicBoolean connected = new AtomicBoolean(true);
         final AtomicBoolean closed = new AtomicBoolean(false);
 
-        when(socket.isConnected()).thenAnswer(new Answer<Boolean>() {
-            @Override
-            public Boolean answer(InvocationOnMock invocation) throws Throwable {
-                return connected.get();
-            }
-        });
-
-        when(socket.isClosed()).thenAnswer(new Answer<Boolean>() {
-            @Override
-            public Boolean answer(InvocationOnMock invocation) throws Throwable {
-                return closed.get();
-            }
-        });
-
-        doAnswer(new Answer<Void>() {
-            @Override
-            public Void answer(InvocationOnMock invocation) throws Throwable {
-                connected.set(false);
-                closed.set(true);
-                return null;
-            }
+        when(socket.isConnected()).thenAnswer(invocation -> connected.get());
+
+        when(socket.isClosed()).thenAnswer(invocation -> closed.get());
+
+        doAnswer(invocation -> {
+            connected.set(false);
+            closed.set(true);
+            return null;
         }).when(socket).close();
 
         when(socket.getOutputStream()).thenReturn(output);
 
         // Mock behavior of socket.getOutputStream().close() calling socket.close();
-        doAnswer(new Answer<Void>() {
-            @Override
-            public Void answer(InvocationOnMock invocation) throws Throwable {
-                invocation.callRealMethod();
-                socket.close();
-                return null;
-            }
+        doAnswer(invocation -> {
+            invocation.callRealMethod();
+            socket.close();
+            return null;
         }).when(output).close();
 
         when(socketFactory.createSocket(any(InetAddress.class),
@@ -94,7 +71,9 @@ public class PickledGraphiteTest {
 
         ScriptEngine engine = new ScriptEngineManager().getEngineByName("python");
         Compilable compilable = (Compilable) engine;
-        unpickleScript = compilable.compile(UNPICKLER_SCRIPT);
+        try (InputStream is = PickledGraphiteTest.class.getResource("/upickle.py").openStream()) {
+            unpickleScript = compilable.compile(new InputStreamReader(is, UTF_8));
+        }
     }
 
     @Test
@@ -170,7 +149,7 @@ public class PickledGraphiteTest {
         }
     }
 
-    String unpickleOutput() throws Exception {
+    private String unpickleOutput() throws Exception {
         StringBuilder results = new StringBuilder();
 
         // the charset is important. if the GraphitePickleReporter and this test
@@ -184,7 +163,7 @@ public class PickledGraphiteTest {
             bindings.put("payload", payload.substring(nextIndex));
             unpickleScript.eval(bindings);
             result.addAll(result.size(), (PyList) bindings.get("metrics"));
-            nextIndex += (Integer) bindings.get("batchLength");
+            nextIndex += ((BigInteger) bindings.get("batchLength")).intValue();
         }
 
         for (Object aResult : result) {
diff --git a/metrics-graphite/src/test/resources/upickle.py b/metrics-graphite/src/test/resources/upickle.py
new file mode 100644
index 0000000..9d51d21
--- /dev/null
+++ b/metrics-graphite/src/test/resources/upickle.py
@@ -0,0 +1,9 @@
+# Pulls apart the pickled payload. This skips ahead 4 characters to safely ignore
+# the header (length)
+import cPickle
+import struct
+format = '!L'
+headerLength = struct.calcsize(format)
+payloadLength, = struct.unpack(format, payload[:headerLength])
+batchLength = headerLength + payloadLength
+metrics = cPickle.loads(payload[headerLength:batchLength])
diff --git a/metrics-healthchecks/pom.xml b/metrics-healthchecks/pom.xml
index c1a64c2..abdf6d9 100644
--- a/metrics-healthchecks/pom.xml
+++ b/metrics-healthchecks/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-healthchecks</artifactId>
@@ -15,13 +15,66 @@
         An addition to Metrics which provides the ability to run application-specific health checks,
         allowing you to check your application's heath in production.
     </description>
-    
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.health</javaModuleName>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-jvm</artifactId>
-            <version>${project.version}</version>
             <optional>true</optional>
         </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/AsyncHealthCheckDecorator.java b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/AsyncHealthCheckDecorator.java
index aef57de..f7deb90 100644
--- a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/AsyncHealthCheckDecorator.java
+++ b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/AsyncHealthCheckDecorator.java
@@ -1,28 +1,34 @@
 package com.codahale.metrics.health;
 
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.health.annotation.Async;
+
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 
-import com.codahale.metrics.health.annotation.Async;
-
 /**
  * A health check decorator to manage asynchronous executions.
  */
-class AsyncHealthCheckDecorator extends HealthCheck implements Runnable {
+public class AsyncHealthCheckDecorator extends HealthCheck implements Runnable {
     private static final String NO_RESULT_YET_MESSAGE = "Waiting for first asynchronous check result.";
     private final HealthCheck healthCheck;
     private final ScheduledFuture<?> future;
+    private final long healthyTtl;
+    private final Clock clock;
     private volatile Result result;
 
-    AsyncHealthCheckDecorator(HealthCheck healthCheck, ScheduledExecutorService executorService) {
+    AsyncHealthCheckDecorator(HealthCheck healthCheck, ScheduledExecutorService executorService, Clock clock) {
         check(healthCheck != null, "healthCheck cannot be null");
         check(executorService != null, "executorService cannot be null");
-        Async async = (Async) healthCheck.getClass().getAnnotation(Async.class);
+        Async async = healthCheck.getClass().getAnnotation(Async.class);
         check(async != null, "healthCheck must contain Async annotation");
         check(async.period() > 0, "period cannot be less than or equal to zero");
         check(async.initialDelay() >= 0, "initialDelay cannot be less than zero");
 
+
+        this.clock = clock;
         this.healthCheck = healthCheck;
+        this.healthyTtl = async.unit().toMillis(async.healthyTtl() <= 0 ? 2 * async.period() : async.healthyTtl());
         result = Async.InitialState.HEALTHY.equals(async.initialState()) ? Result.healthy(NO_RESULT_YET_MESSAGE) :
                 Result.unhealthy(NO_RESULT_YET_MESSAGE);
         if (Async.ScheduleType.FIXED_RATE.equals(async.scheduleType())) {
@@ -33,6 +39,10 @@ class AsyncHealthCheckDecorator extends HealthCheck implements Runnable {
 
     }
 
+    AsyncHealthCheckDecorator(HealthCheck healthCheck, ScheduledExecutorService executorService) {
+        this(healthCheck, executorService, Clock.defaultClock());
+    }
+
     @Override
     public void run() {
         result = healthCheck.execute();
@@ -40,6 +50,17 @@ class AsyncHealthCheckDecorator extends HealthCheck implements Runnable {
 
     @Override
     protected Result check() throws Exception {
+        long expiration = clock.getTime() - result.getTime() - healthyTtl;
+        if (expiration > 0) {
+            return Result.builder()
+                    .unhealthy()
+                    .usingClock(clock)
+                    .withMessage("Result was %s but it expired %d milliseconds ago",
+                            result.isHealthy() ? "healthy" : "unhealthy",
+                            expiration)
+                    .build();
+        }
+
         return result;
     }
 
@@ -47,14 +68,13 @@ class AsyncHealthCheckDecorator extends HealthCheck implements Runnable {
         return future.cancel(true);
     }
 
-    HealthCheck getHealthCheck() {
+    public HealthCheck getHealthCheck() {
         return healthCheck;
     }
 
-    private void check(boolean expression, String message) {
+    private static void check(boolean expression, String message) {
         if (!expression) {
             throw new IllegalArgumentException(message);
         }
     }
-
 }
diff --git a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheck.java b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheck.java
index f2baa81..e123efa 100644
--- a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheck.java
+++ b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheck.java
@@ -1,22 +1,28 @@
 package com.codahale.metrics.health;
 
-import java.text.SimpleDateFormat;
+import com.codahale.metrics.Clock;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
-import java.util.Date;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 /**
  * A health check for a component of your application.
  */
 public abstract class HealthCheck {
+
     /**
      * The result of a {@link HealthCheck} being run. It can be healthy (with an optional message and optional details)
      * or unhealthy (with either an error message or a thrown exception and optional details).
      */
     public static class Result {
-        private static final String DATE_FORMAT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+        private static final DateTimeFormatter DATE_FORMAT_PATTERN =
+                DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
         private static final int PRIME = 31;
 
         /**
@@ -40,10 +46,10 @@ public abstract class HealthCheck {
 
         /**
          * Returns a healthy {@link Result} with a formatted message.
-         * <p/>
+         * <p>
          * Message formatting follows the same rules as {@link String#format(String, Object...)}.
          *
-         * @param message a message format
+         * @param message a message format\\
          * @param args    the arguments apply to the message format
          * @return a healthy {@link Result} with an additional message
          * @see String#format(String, Object...)
@@ -64,7 +70,7 @@ public abstract class HealthCheck {
 
         /**
          * Returns an unhealthy {@link Result} with a formatted message.
-         * <p/>
+         * <p>
          * Message formatting follows the same rules as {@link String#format(String, Object...)}.
          *
          * @param message a message format
@@ -87,7 +93,7 @@ public abstract class HealthCheck {
         }
 
 
-		/**
+        /**
          * Returns a new {@link ResultBuilder}
          *
          * @return the {@link ResultBuilder}
@@ -100,22 +106,24 @@ public abstract class HealthCheck {
         private final String message;
         private final Throwable error;
         private final Map<String, Object> details;
-        private final String timestamp;
+        private final long time;
+
+        private long duration; // Calculated field
 
         private Result(boolean isHealthy, String message, Throwable error) {
-            this(isHealthy, message, error, null);
+            this(isHealthy, message, error, null, Clock.defaultClock());
         }
 
         private Result(ResultBuilder builder) {
-            this(builder.healthy, builder.message, builder.error, builder.details);
+            this(builder.healthy, builder.message, builder.error, builder.details, builder.clock);
         }
 
-        private Result(boolean isHealthy, String message, Throwable error, Map<String, Object> details) {
+        private Result(boolean isHealthy, String message, Throwable error, Map<String, Object> details, Clock clock) {
             this.healthy = isHealthy;
             this.message = message;
             this.error = error;
             this.details = details == null ? null : Collections.unmodifiableMap(details);
-            timestamp = new SimpleDateFormat(DATE_FORMAT_PATTERN).format(new Date());
+            this.time = clock.getTime();
         }
 
         /**
@@ -148,11 +156,41 @@ public abstract class HealthCheck {
         }
 
         /**
-         * Returns the timestamp when the result was created.
+         * Returns the timestamp when the result was created as a formatted String.
+         *
          * @return a formatted timestamp
          */
         public String getTimestamp() {
-            return timestamp;
+            Instant currentInstant = Instant.ofEpochMilli(time);
+            ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(currentInstant, ZoneId.systemDefault());
+            return DATE_FORMAT_PATTERN.format(zonedDateTime);
+        }
+
+        /**
+         * Returns the time when the result was created, in milliseconds since Epoch
+         *
+         * @return the time when the result was created
+         */
+        public long getTime() {
+            return time;
+        }
+
+        /**
+         * Returns the duration in milliseconds that the healthcheck took to run
+         *
+         * @return the duration
+         */
+        public long getDuration() {
+            return duration;
+        }
+
+        /**
+         * Sets the duration in milliseconds. This will indicate the time it took to run the individual healthcheck
+         *
+         * @param duration The duration in milliseconds
+         */
+        public void setDuration(long duration) {
+            this.duration = duration;
         }
 
         public Map<String, Object> getDetails() {
@@ -171,15 +209,15 @@ public abstract class HealthCheck {
             return healthy == result.healthy &&
                     !(error != null ? !error.equals(result.error) : result.error != null) &&
                     !(message != null ? !message.equals(result.message) : result.message != null) &&
-                    !(timestamp != null ? !timestamp.equals(result.timestamp) : result.timestamp != null);
+                    time == result.time;
         }
 
         @Override
         public int hashCode() {
-            int result = (healthy ? 1 : 0);
+            int result = healthy ? 1 : 0;
             result = PRIME * result + (message != null ? message.hashCode() : 0);
             result = PRIME * result + (error != null ? error.hashCode() : 0);
-            result = PRIME * result + (timestamp != null ? timestamp.hashCode() : 0);
+            result = PRIME * result + (Long.hashCode(time));
             return result;
         }
 
@@ -193,12 +231,11 @@ public abstract class HealthCheck {
             if (error != null) {
                 builder.append(", error=").append(error);
             }
-            builder.append(", timestamp=").append(timestamp);
+            builder.append(", duration=").append(duration);
+            builder.append(", timestamp=").append(getTimestamp());
             if (details != null) {
-                Iterator<Map.Entry<String, Object>> it = details.entrySet().iterator();
-                while (it.hasNext()) {
+                for (Map.Entry<String, Object> e : details.entrySet()) {
                     builder.append(", ");
-                    Map.Entry<String, Object> e = it.next();
                     builder.append(e.getKey())
                             .append("=")
                             .append(String.valueOf(e.getValue()));
@@ -218,16 +255,18 @@ public abstract class HealthCheck {
         private String message;
         private Throwable error;
         private Map<String, Object> details;
+        private Clock clock;
 
         protected ResultBuilder() {
             this.healthy = true;
-            this.details = new LinkedHashMap<String, Object>();
+            this.details = new LinkedHashMap<>();
+            this.clock = Clock.defaultClock();
         }
 
         /**
          * Configure an healthy result
          *
-         * @return
+         * @return this builder with healthy status
          */
         public ResultBuilder healthy() {
             this.healthy = true;
@@ -237,7 +276,7 @@ public abstract class HealthCheck {
         /**
          * Configure an unhealthy result
          *
-         * @return
+         * @return this builder with unhealthy status
          */
         public ResultBuilder unhealthy() {
             this.healthy = false;
@@ -248,7 +287,7 @@ public abstract class HealthCheck {
          * Configure an unhealthy result with an {@code error}
          *
          * @param error the error
-         * @return
+         * @return this builder with the given error
          */
         public ResultBuilder unhealthy(Throwable error) {
             this.error = error;
@@ -268,7 +307,7 @@ public abstract class HealthCheck {
 
         /**
          * Set an optional formatted message
-         * <p/>
+         * <p>
          * Message formatting follows the same rules as {@link String#format(String, Object...)}.
          *
          * @param message a message format
@@ -289,12 +328,24 @@ public abstract class HealthCheck {
          */
         public ResultBuilder withDetail(String key, Object data) {
             if (this.details == null) {
-                this.details = new LinkedHashMap<String, Object>();
+                this.details = new LinkedHashMap<>();
             }
             this.details.put(key, data);
             return this;
         }
 
+        /**
+         * Configure this {@link ResultBuilder} to use the given {@code clock} instead of the default clock.
+         * If not specified, the default clock is {@link Clock#defaultClock()}.
+         *
+         * @param clock the {@link Clock} to use when generating the health check timestamp (useful for unit testing)
+         * @return this builder configured to use the given {@code clock}
+         */
+        public ResultBuilder usingClock(Clock clock) {
+            this.clock = clock;
+            return this;
+        }
+
         public Result build() {
             return new Result(this);
         }
@@ -304,7 +355,7 @@ public abstract class HealthCheck {
      * Perform a check of the application component.
      *
      * @return if the component is healthy, a healthy {@link Result}; otherwise, an unhealthy {@link
-     *         Result} with a descriptive error message or exception
+     * Result} with a descriptive error message or exception
      * @throws Exception if there is an unhandled error during the health check; this will result in
      *                   a failed health check
      */
@@ -314,13 +365,21 @@ public abstract class HealthCheck {
      * Executes the health check, catching and handling any exceptions raised by {@link #check()}.
      *
      * @return if the component is healthy, a healthy {@link Result}; otherwise, an unhealthy {@link
-     *         Result} with a descriptive error message or exception
+     * Result} with a descriptive error message or exception
      */
     public Result execute() {
+        long start = clock().getTick();
+        Result result;
         try {
-            return check();
+            result = check();
         } catch (Exception e) {
-            return Result.unhealthy(e);
+            result = Result.unhealthy(e);
         }
+        result.setDuration(TimeUnit.MILLISECONDS.convert(clock().getTick() - start, TimeUnit.NANOSECONDS));
+        return result;
+    }
+
+    protected Clock clock() {
+        return Clock.defaultClock();
     }
 }
diff --git a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheckFilter.java b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheckFilter.java
new file mode 100644
index 0000000..3802cc4
--- /dev/null
+++ b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheckFilter.java
@@ -0,0 +1,21 @@
+package com.codahale.metrics.health;
+
+/**
+ * A filter used to determine whether or not a health check should be reported.
+ */
+@FunctionalInterface
+public interface HealthCheckFilter {
+    /**
+     * Matches all health checks, regardless of type or name.
+     */
+    HealthCheckFilter ALL = (name, healthCheck) -> true;
+
+    /**
+     * Returns {@code true} if the health check matches the filter; {@code false} otherwise.
+     *
+     * @param name        the health check's name
+     * @param healthCheck the health check
+     * @return {@code true} if the health check matches the filter
+     */
+    boolean matches(String name, HealthCheck healthCheck);
+}
diff --git a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheckRegistry.java b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheckRegistry.java
index 754832d..470bbd3 100644
--- a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheckRegistry.java
+++ b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/HealthCheckRegistry.java
@@ -1,9 +1,9 @@
 package com.codahale.metrics.health;
 
-import static com.codahale.metrics.health.HealthCheck.Result;
+import com.codahale.metrics.health.annotation.Async;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -13,22 +13,18 @@ import java.util.SortedMap;
 import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.codahale.metrics.health.annotation.Async;
+import static com.codahale.metrics.health.HealthCheck.Result;
 
 /**
  * A registry for health checks.
@@ -64,8 +60,8 @@ public class HealthCheckRegistry {
      * @param asyncExecutorService executor service for async health check executions
      */
     public HealthCheckRegistry(ScheduledExecutorService asyncExecutorService) {
-        this.healthChecks = new ConcurrentHashMap<String, HealthCheck>();
-        this.listeners = new CopyOnWriteArrayList<HealthCheckRegistryListener>();
+        this.healthChecks = new ConcurrentHashMap<>();
+        this.listeners = new CopyOnWriteArrayList<>();
         this.asyncExecutorService = asyncExecutorService;
     }
 
@@ -99,19 +95,18 @@ public class HealthCheckRegistry {
      * @param healthCheck the {@link HealthCheck} instance
      */
     public void register(String name, HealthCheck healthCheck) {
-        HealthCheck registered = null;
+        HealthCheck registered;
         synchronized (lock) {
-            if (!healthChecks.containsKey(name)) {
-                registered = healthCheck;
-                if (healthCheck.getClass().isAnnotationPresent(Async.class)) {
-                    registered = new AsyncHealthCheckDecorator(healthCheck, asyncExecutorService);
-                }
-                healthChecks.put(name, registered);
+            if (healthChecks.containsKey(name)) {
+                throw new IllegalArgumentException("A health check named " + name + " already exists");
             }
+            registered = healthCheck;
+            if (healthCheck.getClass().isAnnotationPresent(Async.class)) {
+                registered = new AsyncHealthCheckDecorator(healthCheck, asyncExecutorService);
+            }
+            healthChecks.put(name, registered);
         }
-        if (registered != null) {
-            onHealthCheckAdded(name, registered);
-        }
+        onHealthCheckAdded(name, registered);
     }
 
     /**
@@ -120,7 +115,7 @@ public class HealthCheckRegistry {
      * @param name the name of the {@link HealthCheck} instance
      */
     public void unregister(String name) {
-        HealthCheck healthCheck = null;
+        HealthCheck healthCheck;
         synchronized (lock) {
             healthCheck = healthChecks.remove(name);
             if (healthCheck instanceof AsyncHealthCheckDecorator) {
@@ -138,7 +133,16 @@ public class HealthCheckRegistry {
      * @return the names of all registered health checks
      */
     public SortedSet<String> getNames() {
-        return Collections.unmodifiableSortedSet(new TreeSet<String>(healthChecks.keySet()));
+        return Collections.unmodifiableSortedSet(new TreeSet<>(healthChecks.keySet()));
+    }
+
+    /**
+     * Returns the {@link HealthCheck} instance with a given name
+     *
+     * @param name the name of the {@link HealthCheck} instance
+     */
+    public HealthCheck getHealthCheck(String name) {
+        return healthChecks.get(name);
     }
 
     /**
@@ -162,10 +166,24 @@ public class HealthCheckRegistry {
      * @return a map of the health check results
      */
     public SortedMap<String, HealthCheck.Result> runHealthChecks() {
-        final SortedMap<String, HealthCheck.Result> results = new TreeMap<String, HealthCheck.Result>();
+        return runHealthChecks(HealthCheckFilter.ALL);
+    }
+
+    /**
+     * Runs the registered health checks matching the filter and returns a map of the results.
+     *
+     * @param filter health check filter
+     * @return a map of the health check results
+     */
+    public SortedMap<String, HealthCheck.Result> runHealthChecks(HealthCheckFilter filter) {
+        final SortedMap<String, HealthCheck.Result> results = new TreeMap<>();
         for (Map.Entry<String, HealthCheck> entry : healthChecks.entrySet()) {
-            final Result result = entry.getValue().execute();
-            results.put(entry.getKey(), result);
+            final String name = entry.getKey();
+            final HealthCheck healthCheck = entry.getValue();
+            if (filter.matches(name, healthCheck)) {
+                final Result result = entry.getValue().execute();
+                results.put(entry.getKey(), result);
+            }
         }
         return Collections.unmodifiableSortedMap(results);
     }
@@ -177,17 +195,27 @@ public class HealthCheckRegistry {
      * @return a map of the health check results
      */
     public SortedMap<String, HealthCheck.Result> runHealthChecks(ExecutorService executor) {
-        final Map<String, Future<HealthCheck.Result>> futures = new HashMap<String, Future<Result>>();
+        return runHealthChecks(executor, HealthCheckFilter.ALL);
+    }
+
+    /**
+     * Runs the registered health checks matching the filter in parallel and returns a map of the results.
+     *
+     * @param executor object to launch and track health checks progress
+     * @param filter   health check filter
+     * @return a map of the health check results
+     */
+    public SortedMap<String, HealthCheck.Result> runHealthChecks(ExecutorService executor, HealthCheckFilter filter) {
+        final Map<String, Future<HealthCheck.Result>> futures = new HashMap<>();
         for (final Map.Entry<String, HealthCheck> entry : healthChecks.entrySet()) {
-            futures.put(entry.getKey(), executor.submit(new Callable<Result>() {
-                @Override
-                public Result call() throws Exception {
-                    return entry.getValue().execute();
-                }
-            }));
+            final String name = entry.getKey();
+            final HealthCheck healthCheck = entry.getValue();
+            if (filter.matches(name, healthCheck)) {
+                futures.put(name, executor.submit(() -> healthCheck.execute()));
+            }
         }
 
-        final SortedMap<String, HealthCheck.Result> results = new TreeMap<String, HealthCheck.Result>();
+        final SortedMap<String, HealthCheck.Result> results = new TreeMap<>();
         for (Map.Entry<String, Future<Result>> entry : futures.entrySet()) {
             try {
                 results.put(entry.getKey(), entry.getValue().get());
@@ -230,27 +258,12 @@ public class HealthCheckRegistry {
     }
 
     private static ScheduledExecutorService createExecutorService(int corePoolSize) {
-        ScheduledExecutorService asyncExecutorService = Executors.newScheduledThreadPool(corePoolSize,
+        final ScheduledThreadPoolExecutor asyncExecutorService = new ScheduledThreadPoolExecutor(corePoolSize,
                 new NamedThreadFactory("healthcheck-async-executor-"));
-        try {
-            Method method = asyncExecutorService.getClass().getMethod("setRemoveOnCancelPolicy", Boolean.TYPE);
-            method.invoke(asyncExecutorService, true);
-        } catch (NoSuchMethodException e) {
-            logSetExecutorCancellationPolicyFailure(e);
-        } catch (IllegalAccessException e) {
-            logSetExecutorCancellationPolicyFailure(e);
-        } catch (InvocationTargetException e) {
-            logSetExecutorCancellationPolicyFailure(e);
-        }
+        asyncExecutorService.setRemoveOnCancelPolicy(true);
         return asyncExecutorService;
     }
 
-    private static void logSetExecutorCancellationPolicyFailure(Exception e) {
-        LOGGER.warn("Tried but failed to set executor cancellation policy to remove on cancel which has been introduced " +
-                "in Java 7. This could result in a memory leak if many asynchronous health checks are registered and " +
-                "removed because cancellation does not actually remove them from the executor.", e);
-    }
-
     private static class NamedThreadFactory implements ThreadFactory {
 
         private final ThreadGroup group;
diff --git a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/SharedHealthCheckRegistries.java b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/SharedHealthCheckRegistries.java
index e65000e..5f1549c 100644
--- a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/SharedHealthCheckRegistries.java
+++ b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/SharedHealthCheckRegistries.java
@@ -10,9 +10,9 @@ import java.util.concurrent.atomic.AtomicReference;
  */
 public class SharedHealthCheckRegistries {
     private static final ConcurrentMap<String, HealthCheckRegistry> REGISTRIES =
-            new ConcurrentHashMap<String, HealthCheckRegistry>();
+            new ConcurrentHashMap<>();
 
-    private static AtomicReference<String> defaultRegistryName = new AtomicReference<String>();
+    private static AtomicReference<String> defaultRegistryName = new AtomicReference<>();
 
     /* Visible for testing */
     static void setDefaultRegistryName(AtomicReference<String> defaultRegistryName) {
diff --git a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/annotation/Async.java b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/annotation/Async.java
index 91de06f..c8cfc4c 100644
--- a/metrics-healthchecks/src/main/java/com/codahale/metrics/health/annotation/Async.java
+++ b/metrics-healthchecks/src/main/java/com/codahale/metrics/health/annotation/Async.java
@@ -15,15 +15,15 @@ public @interface Async {
     /**
      * Enum representing the initial health states.
      */
-    public enum InitialState {
-        HEALTHY, UNHEALTHY;
+    enum InitialState {
+        HEALTHY, UNHEALTHY
     }
 
     /**
      * Enum representing the possible schedule types.
      */
-    public enum ScheduleType {
-        FIXED_RATE, FIXED_DELAY;
+    enum ScheduleType {
+        FIXED_RATE, FIXED_DELAY
     }
 
     /**
@@ -48,7 +48,7 @@ public @interface Async {
     long initialDelay() default 0;
 
     /**
-     * Time unit of initial delay and period.
+     * Time unit of initial delay, period and healthyTtl.
      *
      * @return time unit
      */
@@ -61,4 +61,15 @@ public @interface Async {
      */
     InitialState initialState() default InitialState.HEALTHY;
 
+    /**
+     * How long a healthy result is considered valid before being ignored.
+     *
+     * Handles cases where the asynchronous healthcheck did not run (for example thread starvation).
+     *
+     * Defaults to 2 * period
+     *
+     * @return healthy result time to live
+     */
+    long healthyTtl() default -1;
+
 }
diff --git a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/AsyncHealthCheckDecoratorTest.java b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/AsyncHealthCheckDecoratorTest.java
index f3f2f61..5e0d87d 100644
--- a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/AsyncHealthCheckDecoratorTest.java
+++ b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/AsyncHealthCheckDecoratorTest.java
@@ -1,29 +1,43 @@
 package com.codahale.metrics.health;
 
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.health.annotation.Async;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentCaptor.forClass;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
-
-import org.junit.Test;
-import org.mockito.ArgumentCaptor;
-
-import com.codahale.metrics.health.annotation.Async;
-
 /**
  * Unit tests for {@link AsyncHealthCheckDecorator}.
  */
 public class AsyncHealthCheckDecoratorTest {
+
+    private static final long CURRENT_TIME = 1551002401000L;
+    
+    private static final Clock FIXED_CLOCK = clockWithFixedTime(CURRENT_TIME);
+
+    private static final HealthCheck.Result EXPECTED_EXPIRED_RESULT = HealthCheck.Result
+            .builder()
+            .usingClock(FIXED_CLOCK)
+            .unhealthy()
+            .withMessage("Result was healthy but it expired 1 milliseconds ago")
+            .build();
+    
     private final HealthCheck mockHealthCheck = mock(HealthCheck.class);
     private final ScheduledExecutorService mockExecutorService = mock(ScheduledExecutorService.class);
+
+    @SuppressWarnings("rawtypes")
     private final ScheduledFuture mockFuture = mock(ScheduledFuture.class);
 
     @Test(expected = IllegalArgumentException.class)
@@ -86,6 +100,7 @@ public class AsyncHealthCheckDecoratorTest {
     }
 
     @Test
+    @SuppressWarnings("unchecked")
     public void tearDownTriggersCancellation() throws Exception {
         when(mockExecutorService.scheduleAtFixedRate(any(Runnable.class), eq(0L), eq(1L), eq(TimeUnit.SECONDS))).
                 thenReturn(mockFuture);
@@ -100,6 +115,7 @@ public class AsyncHealthCheckDecoratorTest {
     }
 
     @Test
+    @SuppressWarnings("unchecked")
     public void afterFirstExecutionDecoratedHealthCheckResultIsProvided() throws Exception {
         HealthCheck.Result expectedResult = HealthCheck.Result.healthy("AsyncHealthCheckTest");
         when(mockExecutorService.scheduleAtFixedRate(any(Runnable.class), eq(0L), eq(1L), eq(TimeUnit.SECONDS)))
@@ -121,6 +137,7 @@ public class AsyncHealthCheckDecoratorTest {
     }
 
     @Test
+    @SuppressWarnings("unchecked")
     public void exceptionInDecoratedHealthCheckWontAffectAsyncDecorator() throws Exception {
         Exception exception = new Exception("TestException");
         when(mockExecutorService.scheduleAtFixedRate(any(Runnable.class), eq(0L), eq(1L), eq(TimeUnit.SECONDS)))
@@ -140,11 +157,59 @@ public class AsyncHealthCheckDecoratorTest {
         assertThat(result.getError()).isEqualTo(exception);
     }
 
+    @Test
+    public void returnUnhealthyIfPreviousResultIsExpiredBasedOnTtl() throws Exception {
+        HealthCheck healthCheck = new HealthyAsyncHealthCheckWithExpiredExplicitTtlInMilliseconds();
+        AsyncHealthCheckDecorator asyncDecorator = new AsyncHealthCheckDecorator(healthCheck, mockExecutorService, FIXED_CLOCK);
+        
+        ArgumentCaptor<Runnable> runnableCaptor = forClass(Runnable.class);
+        verify(mockExecutorService, times(1)).scheduleAtFixedRate(runnableCaptor.capture(),
+                eq(0L), eq(1000L), eq(TimeUnit.MILLISECONDS));
+        Runnable capturedRunnable = runnableCaptor.getValue();
+        capturedRunnable.run();
+
+        HealthCheck.Result result = asyncDecorator.check();
+
+        assertThat(result).isEqualTo(EXPECTED_EXPIRED_RESULT);
+    }
+
+    @Test
+    public void returnUnhealthyIfPreviousResultIsExpiredBasedOnPeriod() throws Exception {
+        HealthCheck healthCheck = new HealthyAsyncHealthCheckWithExpiredTtlInMillisecondsBasedOnPeriod();
+        AsyncHealthCheckDecorator asyncDecorator = new AsyncHealthCheckDecorator(healthCheck, mockExecutorService, FIXED_CLOCK);
+
+        ArgumentCaptor<Runnable> runnableCaptor = forClass(Runnable.class);
+        verify(mockExecutorService, times(1)).scheduleAtFixedRate(runnableCaptor.capture(),
+                eq(0L), eq(1000L), eq(TimeUnit.MILLISECONDS));
+        Runnable capturedRunnable = runnableCaptor.getValue();
+        capturedRunnable.run();
+
+        HealthCheck.Result result = asyncDecorator.check();
+
+        assertThat(result).isEqualTo(EXPECTED_EXPIRED_RESULT);
+    }
+
+    @Test
+    public void convertTtlToMillisecondsWhenCheckingExpiration() throws Exception {
+        HealthCheck healthCheck = new HealthyAsyncHealthCheckWithExpiredExplicitTtlInSeconds();
+        AsyncHealthCheckDecorator asyncDecorator = new AsyncHealthCheckDecorator(healthCheck, mockExecutorService, FIXED_CLOCK);
+
+        ArgumentCaptor<Runnable> runnableCaptor = forClass(Runnable.class);
+        verify(mockExecutorService, times(1)).scheduleAtFixedRate(runnableCaptor.capture(),
+                eq(0L), eq(1L), eq(TimeUnit.SECONDS));
+        Runnable capturedRunnable = runnableCaptor.getValue();
+        capturedRunnable.run();
+
+        HealthCheck.Result result = asyncDecorator.check();
+
+        assertThat(result).isEqualTo(EXPECTED_EXPIRED_RESULT);
+    }
+
     @Async(period = -1)
     private static class NegativePeriodAsyncHealthCheck extends HealthCheck {
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return null;
         }
     }
@@ -153,7 +218,7 @@ public class AsyncHealthCheckDecoratorTest {
     private static class ZeroPeriodAsyncHealthCheck extends HealthCheck {
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return null;
         }
     }
@@ -162,7 +227,7 @@ public class AsyncHealthCheckDecoratorTest {
     private static class NegativeInitialDelayAsyncHealthCheck extends HealthCheck {
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return null;
         }
     }
@@ -171,7 +236,7 @@ public class AsyncHealthCheckDecoratorTest {
     private static class DefaultAsyncHealthCheck extends HealthCheck {
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return null;
         }
     }
@@ -180,7 +245,7 @@ public class AsyncHealthCheckDecoratorTest {
     private static class FixedDelayAsyncHealthCheck extends HealthCheck {
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return null;
         }
     }
@@ -189,7 +254,7 @@ public class AsyncHealthCheckDecoratorTest {
     private static class UnhealthyAsyncHealthCheck extends HealthCheck {
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return null;
         }
     }
@@ -221,4 +286,45 @@ public class AsyncHealthCheckDecoratorTest {
         }
     }
 
+    @Async(period = 1000, initialState = Async.InitialState.UNHEALTHY, healthyTtl = 3000, unit = TimeUnit.MILLISECONDS)
+    private static class HealthyAsyncHealthCheckWithExpiredExplicitTtlInMilliseconds extends HealthCheck {
+
+        @Override
+        protected Result check() {
+            return Result.builder().usingClock(clockWithFixedTime(CURRENT_TIME - 3001L)).healthy().build();
+        }
+    }
+
+    @Async(period = 1, initialState = Async.InitialState.UNHEALTHY, healthyTtl = 5, unit = TimeUnit.SECONDS)
+    private static class HealthyAsyncHealthCheckWithExpiredExplicitTtlInSeconds extends HealthCheck {
+
+        @Override
+        protected Result check() {
+            return Result.builder().usingClock(clockWithFixedTime(CURRENT_TIME - 5001L)).healthy().build();
+        }
+    }
+
+    @Async(period = 1000, initialState = Async.InitialState.UNHEALTHY, unit = TimeUnit.MILLISECONDS)
+    private static class HealthyAsyncHealthCheckWithExpiredTtlInMillisecondsBasedOnPeriod extends HealthCheck {
+
+        @Override
+        protected Result check() {
+            return Result.builder().usingClock(clockWithFixedTime(CURRENT_TIME - 2001L)).healthy().build();
+        }
+    }
+
+    private static Clock clockWithFixedTime(final long time) {
+        return new Clock() {
+            @Override
+            public long getTick() {
+                return 0;
+            }
+
+            @Override
+            public long getTime() {
+                return time;
+            }
+        };
+    }
+
 }
diff --git a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckFilterTest.java b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckFilterTest.java
new file mode 100644
index 0000000..1680111
--- /dev/null
+++ b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckFilterTest.java
@@ -0,0 +1,14 @@
+package com.codahale.metrics.health;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import org.junit.Test;
+
+public class HealthCheckFilterTest {
+
+    @Test
+    public void theAllFilterMatchesAllHealthChecks() {
+        assertThat(HealthCheckFilter.ALL.matches("", mock(HealthCheck.class))).isTrue();
+    }
+}
diff --git a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckRegistryTest.java b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckRegistryTest.java
index 3d3171b..bffaf89 100644
--- a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckRegistryTest.java
+++ b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckRegistryTest.java
@@ -4,8 +4,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.entry;
 import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown;
 import static org.mockito.ArgumentCaptor.forClass;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -37,16 +37,19 @@ public class HealthCheckRegistryTest {
 
     private final HealthCheck.Result ar = mock(HealthCheck.Result.class);
     private final HealthCheck ahc = new TestAsyncHealthCheck(ar);
+
+    @SuppressWarnings("rawtypes")
     private final ScheduledFuture af = mock(ScheduledFuture.class);
 
     @Before
-    public void setUp() throws Exception {
+    @SuppressWarnings("unchecked")
+    public void setUp() {
         registry.addListener(listener);
 
         when(hc1.execute()).thenReturn(r1);
         when(hc2.execute()).thenReturn(r2);
-        when(executorService.scheduleAtFixedRate(any(AsyncHealthCheckDecorator.class),eq(0L), eq(10L), eq(TimeUnit
-                .SECONDS))).thenReturn(af);
+        when(executorService.scheduleAtFixedRate(any(AsyncHealthCheckDecorator.class), eq(0L), eq(10L), eq(TimeUnit.SECONDS)))
+            .thenReturn(af);
 
         registry.register("hc1", hc1);
         registry.register("hc2", hc2);
@@ -67,6 +70,11 @@ public class HealthCheckRegistryTest {
         verify(af).cancel(true);
     }
 
+    @Test(expected = IllegalArgumentException.class)
+    public void registeringHealthCheckTwiceThrowsException() {
+        registry.register("hc1", hc1);
+    }
+
     @Test
     public void registeringHealthCheckTriggersNotification() {
         verify(listener).onHealthCheckAdded("hc1", hc1);
@@ -112,7 +120,7 @@ public class HealthCheckRegistryTest {
     }
 
     @Test
-    public void runsRegisteredHealthChecks() throws Exception {
+    public void runsRegisteredHealthChecks() {
         final Map<String, HealthCheck.Result> results = registry.runHealthChecks();
 
         assertThat(results).contains(entry("hc1", r1));
@@ -120,6 +128,20 @@ public class HealthCheckRegistryTest {
         assertThat(results).containsKey("ahc");
     }
 
+    @Test
+    public void runsRegisteredHealthChecksWithFilter() {
+        final Map<String, HealthCheck.Result> results = registry.runHealthChecks((name, healthCheck) -> "hc1".equals(name));
+
+        assertThat(results).containsOnly(entry("hc1", r1));
+    }
+
+    @Test
+    public void runsRegisteredHealthChecksWithNonMatchingFilter() {
+        final Map<String, HealthCheck.Result> results = registry.runHealthChecks((name, healthCheck) -> false);
+
+        assertThat(results).isEmpty();
+    }
+
     @Test
     public void runsRegisteredHealthChecksInParallel() throws Exception {
         final ExecutorService executor = Executors.newFixedThreadPool(10);
@@ -134,7 +156,30 @@ public class HealthCheckRegistryTest {
     }
 
     @Test
-    public void removesRegisteredHealthChecks() throws Exception {
+    public void runsRegisteredHealthChecksInParallelWithNonMatchingFilter() throws Exception {
+        final ExecutorService executor = Executors.newFixedThreadPool(10);
+        final Map<String, HealthCheck.Result> results = registry.runHealthChecks(executor, (name, healthCheck) -> false);
+
+        executor.shutdown();
+        executor.awaitTermination(1, TimeUnit.SECONDS);
+
+        assertThat(results).isEmpty();
+    }
+
+    @Test
+    public void runsRegisteredHealthChecksInParallelWithFilter() throws Exception {
+        final ExecutorService executor = Executors.newFixedThreadPool(10);
+        final Map<String, HealthCheck.Result> results = registry.runHealthChecks(executor,
+            (name, healthCheck) -> "hc2".equals(name));
+
+        executor.shutdown();
+        executor.awaitTermination(1, TimeUnit.SECONDS);
+
+        assertThat(results).containsOnly(entry("hc2", r2));
+    }
+
+    @Test
+    public void removesRegisteredHealthChecks() {
         registry.unregister("hc1");
 
         final Map<String, HealthCheck.Result> results = registry.runHealthChecks();
@@ -145,23 +190,23 @@ public class HealthCheckRegistryTest {
     }
 
     @Test
-    public void hasASetOfHealthCheckNames() throws Exception {
+    public void hasASetOfHealthCheckNames() {
         assertThat(registry.getNames()).containsOnly("hc1", "hc2", "ahc");
     }
 
     @Test
-    public void runsHealthChecksByName() throws Exception {
+    public void runsHealthChecksByName() {
         assertThat(registry.runHealthCheck("hc1")).isEqualTo(r1);
     }
 
     @Test
-    public void doesNotRunNonexistentHealthChecks() throws Exception {
+    public void doesNotRunNonexistentHealthChecks()  {
         try {
             registry.runHealthCheck("what");
             failBecauseExceptionWasNotThrown(NoSuchElementException.class);
         } catch (NoSuchElementException e) {
             assertThat(e.getMessage())
-                    .isEqualTo("No health check named what exists");
+                .isEqualTo("No health check named what exists");
         }
 
     }
@@ -175,7 +220,7 @@ public class HealthCheckRegistryTest {
         }
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return result;
         }
     }
diff --git a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckTest.java b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckTest.java
index 8d3a88a..13c5b3a 100644
--- a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckTest.java
+++ b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/HealthCheckTest.java
@@ -1,12 +1,22 @@
 package com.codahale.metrics.health;
 
+import com.codahale.metrics.Clock;
+import org.junit.Test;
+
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
-import org.junit.Test;
-
 public class HealthCheckTest {
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+
     private static class ExampleHealthCheck extends HealthCheck {
         private final HealthCheck underlying;
 
@@ -15,7 +25,7 @@ public class HealthCheckTest {
         }
 
         @Override
-        protected Result check() throws Exception {
+        protected Result check() {
             return underlying.execute();
         }
     }
@@ -24,7 +34,7 @@ public class HealthCheckTest {
     private final HealthCheck healthCheck = new ExampleHealthCheck(underlying);
 
     @Test
-    public void canHaveHealthyResults() throws Exception {
+    public void canHaveHealthyResults() {
         final HealthCheck.Result result = HealthCheck.Result.healthy();
 
         assertThat(result.isHealthy())
@@ -38,7 +48,7 @@ public class HealthCheckTest {
     }
 
     @Test
-    public void canHaveHealthyResultsWithMessages() throws Exception {
+    public void canHaveHealthyResultsWithMessages() {
         final HealthCheck.Result result = HealthCheck.Result.healthy("woo");
 
         assertThat(result.isHealthy())
@@ -52,7 +62,7 @@ public class HealthCheckTest {
     }
 
     @Test
-    public void canHaveHealthyResultsWithFormattedMessages() throws Exception {
+    public void canHaveHealthyResultsWithFormattedMessages() {
         final HealthCheck.Result result = HealthCheck.Result.healthy("foo %s", "bar");
 
         assertThat(result.isHealthy())
@@ -66,7 +76,7 @@ public class HealthCheckTest {
     }
 
     @Test
-    public void canHaveUnhealthyResults() throws Exception {
+    public void canHaveUnhealthyResults() {
         final HealthCheck.Result result = HealthCheck.Result.unhealthy("bad");
 
         assertThat(result.isHealthy())
@@ -80,7 +90,7 @@ public class HealthCheckTest {
     }
 
     @Test
-    public void canHaveUnhealthyResultsWithFormattedMessages() throws Exception {
+    public void canHaveUnhealthyResultsWithFormattedMessages() {
         final HealthCheck.Result result = HealthCheck.Result.unhealthy("foo %s %d", "bar", 123);
 
         assertThat(result.isHealthy())
@@ -94,7 +104,7 @@ public class HealthCheckTest {
     }
 
     @Test
-    public void canHaveUnhealthyResultsWithExceptions() throws Exception {
+    public void canHaveUnhealthyResultsWithExceptions() {
         final RuntimeException e = mock(RuntimeException.class);
         when(e.getMessage()).thenReturn("oh noes");
 
@@ -111,80 +121,96 @@ public class HealthCheckTest {
     }
 
     @Test
-    public void canHaveHealthyBuilderWithDetail() throws Exception {
+    public void canHaveHealthyBuilderWithFormattedMessage() {
+        final HealthCheck.Result result = HealthCheck.Result.builder()
+                .healthy()
+                .withMessage("There are %d %s in the %s", 42, "foos", "bar")
+                .build();
+
+        assertThat(result.isHealthy())
+                .isTrue();
+
+        assertThat(result.getMessage())
+                .isEqualTo("There are 42 foos in the bar");
+    }
+
+    @Test
+    public void canHaveHealthyBuilderWithDetail() {
         final HealthCheck.Result result = HealthCheck.Result.builder()
-            .healthy()
-            .withDetail("detail", "value")
-            .build();
+                .healthy()
+                .withDetail("detail", "value")
+                .build();
 
         assertThat(result.isHealthy())
-            .isTrue();
+                .isTrue();
 
         assertThat(result.getMessage())
-            .isNull();
+                .isNull();
 
         assertThat(result.getError())
-            .isNull();
+                .isNull();
 
         assertThat(result.getDetails())
-            .containsEntry("detail", "value");
+                .containsEntry("detail", "value");
     }
 
     @Test
-    public void canHaveUnHealthyBuilderWithDetail() throws Exception {
+    public void canHaveUnHealthyBuilderWithDetail() {
         final HealthCheck.Result result = HealthCheck.Result.builder()
-            .unhealthy()
-            .withDetail("detail", "value")
-            .build();
+                .unhealthy()
+                .withDetail("detail", "value")
+                .build();
 
         assertThat(result.isHealthy())
-            .isFalse();
+                .isFalse();
 
         assertThat(result.getMessage())
-            .isNull();
+                .isNull();
 
         assertThat(result.getError())
-            .isNull();
+                .isNull();
 
         assertThat(result.getDetails())
-            .containsEntry("detail", "value");
+                .containsEntry("detail", "value");
     }
 
     @Test
-    public void canHaveUnHealthyBuilderWithDetailAndError() throws Exception {
+    public void canHaveUnHealthyBuilderWithDetailAndError() {
         final RuntimeException e = mock(RuntimeException.class);
         when(e.getMessage()).thenReturn("oh noes");
 
         final HealthCheck.Result result = HealthCheck.Result
-            .builder()
-            .unhealthy(e)
-            .withDetail("detail", "value")
-            .build();
+                .builder()
+                .unhealthy(e)
+                .withDetail("detail", "value")
+                .build();
 
         assertThat(result.isHealthy())
-            .isFalse();
+                .isFalse();
 
         assertThat(result.getMessage())
-            .isEqualTo("oh noes");
+                .isEqualTo("oh noes");
 
         assertThat(result.getError())
-            .isEqualTo(e);
+                .isEqualTo(e);
 
         assertThat(result.getDetails())
-            .containsEntry("detail", "value");
+                .containsEntry("detail", "value");
     }
 
     @Test
-    public void returnsResultsWhenExecuted() throws Exception {
+    public void returnsResultsWhenExecuted() {
         final HealthCheck.Result result = mock(HealthCheck.Result.class);
         when(underlying.execute()).thenReturn(result);
 
         assertThat(healthCheck.execute())
                 .isEqualTo(result);
+
+        verify(result).setDuration(anyLong());
     }
 
     @Test
-    public void wrapsExceptionsWhenExecuted() throws Exception {
+    public void wrapsExceptionsWhenExecuted() {
         final RuntimeException e = mock(RuntimeException.class);
         when(e.getMessage()).thenReturn("oh noes");
 
@@ -199,17 +225,55 @@ public class HealthCheckTest {
                 .isEqualTo(e);
         assertThat(actual.getDetails())
                 .isNull();
+        assertThat(actual.getDuration())
+                .isGreaterThanOrEqualTo(0);
+    }
+
+    @Test
+    public void canHaveUserSuppliedClockForTimestamp() {
+        ZonedDateTime dateTime = ZonedDateTime.now().minusMinutes(10);
+        Clock clock = clockWithFixedTime(dateTime);
+
+        HealthCheck.Result result = HealthCheck.Result.builder()
+                .healthy()
+                .usingClock(clock)
+                .build();
+
+        assertThat(result.isHealthy()).isTrue();
+
+        assertThat(result.getTime()).isEqualTo(clock.getTime());
+
+        assertThat(result.getTimestamp())
+                .isEqualTo(DATE_TIME_FORMATTER.format(dateTime));
     }
 
     @Test
-    public void toStringWorksEvenForNullAttributes() throws Exception {
+    public void toStringWorksEvenForNullAttributes() {
+        ZonedDateTime dateTime = ZonedDateTime.now().minusMinutes(25);
+        Clock clock = clockWithFixedTime(dateTime);
+
         final HealthCheck.Result resultWithNullDetailValue = HealthCheck.Result.builder()
-           .unhealthy()
-           .withDetail("aNullDetail", null)
-           .build();
+                .unhealthy()
+                .withDetail("aNullDetail", null)
+                .usingClock(clock)
+                .build();
         assertThat(resultWithNullDetailValue.toString())
-           .contains(
-              "Result{isHealthy=false, timestamp=", // Skip the timestamp part of the String.
-              ", aNullDetail=null}");
+                .contains(
+                        "Result{isHealthy=false, duration=0, timestamp=" + DATE_TIME_FORMATTER.format(dateTime),
+                        ", aNullDetail=null}");
+    }
+
+    private static Clock clockWithFixedTime(ZonedDateTime dateTime) {
+        return new Clock() {
+            @Override
+            public long getTick() {
+                return 0;
+            }
+
+            @Override
+            public long getTime() {
+                return dateTime.toInstant().toEpochMilli();
+            }
+        };
     }
 }
diff --git a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/SharedHealthCheckRegistriesTest.java b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/SharedHealthCheckRegistriesTest.java
index c9c0254..b7a1b12 100644
--- a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/SharedHealthCheckRegistriesTest.java
+++ b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/SharedHealthCheckRegistriesTest.java
@@ -16,7 +16,7 @@ public class SharedHealthCheckRegistriesTest {
 
     @Before
     public void setUp() {
-        SharedHealthCheckRegistries.setDefaultRegistryName(new AtomicReference<String>());
+        SharedHealthCheckRegistries.setDefaultRegistryName(new AtomicReference<>());
         SharedHealthCheckRegistries.clear();
     }
 
diff --git a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/jvm/ThreadDeadlockHealthCheckTest.java b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/jvm/ThreadDeadlockHealthCheckTest.java
index 43599c0..e878ff7 100644
--- a/metrics-healthchecks/src/test/java/com/codahale/metrics/health/jvm/ThreadDeadlockHealthCheckTest.java
+++ b/metrics-healthchecks/src/test/java/com/codahale/metrics/health/jvm/ThreadDeadlockHealthCheckTest.java
@@ -14,19 +14,19 @@ import static org.mockito.Mockito.when;
 
 public class ThreadDeadlockHealthCheckTest {
     @Test
-    public void isHealthyIfNoThreadsAreDeadlocked() throws Exception {
+    public void isHealthyIfNoThreadsAreDeadlocked() {
         final ThreadDeadlockDetector detector = mock(ThreadDeadlockDetector.class);
         final ThreadDeadlockHealthCheck healthCheck = new ThreadDeadlockHealthCheck(detector);
 
-        when(detector.getDeadlockedThreads()).thenReturn(Collections.<String>emptySet());
+        when(detector.getDeadlockedThreads()).thenReturn(Collections.emptySet());
 
         assertThat(healthCheck.execute().isHealthy())
                 .isTrue();
     }
 
     @Test
-    public void isUnhealthyIfThreadsAreDeadlocked() throws Exception {
-        final Set<String> threads = new TreeSet<String>();
+    public void isUnhealthyIfThreadsAreDeadlocked() {
+        final Set<String> threads = new TreeSet<>();
         threads.add("one");
         threads.add("two");
 
@@ -45,7 +45,7 @@ public class ThreadDeadlockHealthCheckTest {
     }
 
     @Test
-    public void automaticallyUsesThePlatformThreadBeans() throws Exception {
+    public void automaticallyUsesThePlatformThreadBeans() {
         final ThreadDeadlockHealthCheck healthCheck = new ThreadDeadlockHealthCheck();
         assertThat(healthCheck.execute().isHealthy())
                 .isTrue();
diff --git a/metrics-httpasyncclient/pom.xml b/metrics-httpasyncclient/pom.xml
index d5c0870..4cbbb58 100644
--- a/metrics-httpasyncclient/pom.xml
+++ b/metrics-httpasyncclient/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-httpasyncclient</artifactId>
@@ -16,26 +16,92 @@
         durations and rates, and other useful information.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.httpasyncclient</javaModuleName>
+        <http-async-client.version>4.1.5</http-async-client.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.httpcomponents</groupId>
+                <artifactId>httpclient</artifactId>
+                <version>4.5.14</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.httpcomponents</groupId>
+                <artifactId>httpcore</artifactId>
+                <version>4.4.16</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.httpcomponents</groupId>
+                <artifactId>httpcore-nio</artifactId>
+                <version>4.4.16</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-httpclient</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>org.apache.httpcomponents</groupId>
             <artifactId>httpasyncclient</artifactId>
-            <version>4.1.2</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>org.apache.httpcomponents</groupId>
-                    <artifactId>httpclient</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>org.apache.httpcomponents</groupId>
-                    <artifactId>httpcore</artifactId>
-                </exclusion>
-            </exclusions>
+            <version>${http-async-client.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpclient</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpcore</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpcore-nio</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 </project>
diff --git a/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNClientConnManager.java b/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNClientConnManager.java
index dbcc0cb..e541f5d 100644
--- a/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNClientConnManager.java
+++ b/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNClientConnManager.java
@@ -1,9 +1,6 @@
 package com.codahale.metrics.httpasyncclient;
 
-import com.codahale.metrics.Gauge;
 import com.codahale.metrics.MetricRegistry;
-import static com.codahale.metrics.MetricRegistry.name;
-import java.util.concurrent.TimeUnit;
 import org.apache.http.config.Registry;
 import org.apache.http.conn.DnsResolver;
 import org.apache.http.conn.SchemePortResolver;
@@ -14,42 +11,26 @@ import org.apache.http.nio.conn.NHttpConnectionFactory;
 import org.apache.http.nio.conn.SchemeIOSessionStrategy;
 import org.apache.http.nio.reactor.ConnectingIOReactor;
 
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
 public class InstrumentedNClientConnManager extends PoolingNHttpClientConnectionManager {
 
     public InstrumentedNClientConnManager(final ConnectingIOReactor ioreactor, final NHttpConnectionFactory<ManagedNHttpClientConnection> connFactory, final SchemePortResolver schemePortResolver, final MetricRegistry metricRegistry, final Registry<SchemeIOSessionStrategy> iosessionFactoryRegistry, final long timeToLive, final TimeUnit tunit, final DnsResolver dnsResolver, final String name) {
         super(ioreactor, connFactory, iosessionFactoryRegistry, schemePortResolver, dnsResolver, timeToLive, tunit);
-        metricRegistry.register(name(NHttpClientConnectionManager.class, name, "available-connections"),
-                new Gauge<Integer>() {
-                    @Override
-                    public Integer getValue() {
-                        // this acquires a lock on the connection pool; remove if contention sucks
-                        return getTotalStats().getAvailable();
-                    }
-                });
-        metricRegistry.register(name(NHttpClientConnectionManager.class, name, "leased-connections"),
-                new Gauge<Integer>() {
-                    @Override
-                    public Integer getValue() {
-                        // this acquires a lock on the connection pool; remove if contention sucks
-                        return getTotalStats().getLeased();
-                    }
-                });
-        metricRegistry.register(name(NHttpClientConnectionManager.class, name, "max-connections"),
-                new Gauge<Integer>() {
-                    @Override
-                    public Integer getValue() {
-                        // this acquires a lock on the connection pool; remove if contention sucks
-                        return getTotalStats().getMax();
-                    }
-                });
-        metricRegistry.register(name(NHttpClientConnectionManager.class, name, "pending-connections"),
-                new Gauge<Integer>() {
-                    @Override
-                    public Integer getValue() {
-                        // this acquires a lock on the connection pool; remove if contention sucks
-                        return getTotalStats().getPending();
-                    }
-                });
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(NHttpClientConnectionManager.class, name, "available-connections"),
+                () -> getTotalStats().getAvailable());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(NHttpClientConnectionManager.class, name, "leased-connections"),
+                () -> getTotalStats().getLeased());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(NHttpClientConnectionManager.class, name, "max-connections"),
+                () -> getTotalStats().getMax());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(NHttpClientConnectionManager.class, name, "pending-connections"),
+                () -> getTotalStats().getPending());
     }
 
 }
diff --git a/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNHttpClientBuilder.java b/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNHttpClientBuilder.java
index 27365d4..721ca09 100644
--- a/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNHttpClientBuilder.java
+++ b/metrics-httpasyncclient/src/main/java/com/codahale/metrics/httpasyncclient/InstrumentedNHttpClientBuilder.java
@@ -4,8 +4,10 @@ import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.Timer;
 import com.codahale.metrics.httpclient.HttpClientMetricNameStrategies;
 import com.codahale.metrics.httpclient.HttpClientMetricNameStrategy;
+
 import java.io.IOException;
 import java.util.concurrent.Future;
+
 import org.apache.http.HttpException;
 import org.apache.http.HttpRequest;
 import org.apache.http.concurrent.FutureCallback;
@@ -15,6 +17,8 @@ import org.apache.http.nio.protocol.HttpAsyncRequestProducer;
 import org.apache.http.nio.protocol.HttpAsyncResponseConsumer;
 import org.apache.http.protocol.HttpContext;
 
+import static java.util.Objects.requireNonNull;
+
 public class InstrumentedNHttpClientBuilder extends HttpAsyncClientBuilder {
     private final MetricRegistry metricRegistry;
     private final String name;
@@ -63,16 +67,11 @@ public class InstrumentedNHttpClientBuilder extends HttpAsyncClientBuilder {
                 final Timer.Context timerContext;
                 try {
                     timerContext = timer(requestProducer.generateRequest()).time();
-                } catch (IOException ex) {
-                    throw new AssertionError(ex);
-                } catch (HttpException ex) {
-                    throw new AssertionError(ex);
-                }
-                try {
-                    return ac.execute(requestProducer, responseConsumer, context, callback);
-                } finally {
-                    timerContext.stop();
+                } catch (IOException | HttpException ex) {
+                    throw new RuntimeException(ex);
                 }
+                return ac.execute(requestProducer, responseConsumer, context,
+                        new TimingFutureCallback<>(callback, timerContext));
             }
 
             @Override
@@ -82,4 +81,39 @@ public class InstrumentedNHttpClientBuilder extends HttpAsyncClientBuilder {
         };
     }
 
+    private static class TimingFutureCallback<T> implements FutureCallback<T> {
+        private final FutureCallback<T> callback;
+        private final Timer.Context timerContext;
+
+        private TimingFutureCallback(FutureCallback<T> callback,
+                                     Timer.Context timerContext) {
+            this.callback = callback;
+            this.timerContext = requireNonNull(timerContext, "timerContext");
+        }
+
+        @Override
+        public void completed(T result) {
+            timerContext.stop();
+            if (callback != null) {
+                callback.completed(result);
+            }
+        }
+
+        @Override
+        public void failed(Exception ex) {
+            timerContext.stop();
+            if (callback != null) {
+                callback.failed(ex);
+            }
+        }
+
+        @Override
+        public void cancelled() {
+            timerContext.stop();
+            if (callback != null) {
+                callback.cancelled();
+            }
+        }
+    }
+
 }
diff --git a/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/HttpClientTestBase.java b/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/HttpClientTestBase.java
new file mode 100644
index 0000000..12ff992
--- /dev/null
+++ b/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/HttpClientTestBase.java
@@ -0,0 +1,71 @@
+package com.codahale.metrics.httpasyncclient;
+
+import org.apache.http.HttpHost;
+import org.apache.http.impl.nio.bootstrap.HttpServer;
+import org.apache.http.impl.nio.bootstrap.ServerBootstrap;
+import org.apache.http.nio.protocol.BasicAsyncRequestHandler;
+import org.apache.http.nio.reactor.ListenerEndpoint;
+import org.apache.http.protocol.HttpRequestHandler;
+import org.junit.After;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.util.concurrent.TimeUnit;
+
+public abstract class HttpClientTestBase {
+
+    /**
+     * {@link HttpRequestHandler} that responds with a {@code 200 OK}.
+     */
+    public static final HttpRequestHandler STATUS_OK = (request, response, context) -> response.setStatusCode(200);
+
+    private HttpServer server;
+
+    /**
+     * @return A free local port or {@code -1} on error.
+     */
+    public static int findAvailableLocalPort() {
+        try (ServerSocket socket = new ServerSocket(0)) {
+            return socket.getLocalPort();
+        } catch (IOException e) {
+            return -1;
+        }
+    }
+
+    /**
+     * Start a local server that uses the {@code handler} to handle requests.
+     * <p>
+     * The server will be (if started) terminated in the {@link #tearDown()} {@link After} method.
+     *
+     * @param handler The request handler that will be used to respond to every request.
+     * @return The {@link HttpHost} of the server
+     * @throws IOException          in case it's not possible to start the server
+     * @throws InterruptedException in case the server's main thread was interrupted
+     */
+    public HttpHost startServerWithGlobalRequestHandler(HttpRequestHandler handler)
+            throws IOException, InterruptedException {
+        // If there is an existing instance, terminate it
+        tearDown();
+
+        ServerBootstrap serverBootstrap = ServerBootstrap.bootstrap();
+
+        serverBootstrap.registerHandler("/*", new BasicAsyncRequestHandler(handler));
+
+        server = serverBootstrap.create();
+        server.start();
+
+        ListenerEndpoint endpoint = server.getEndpoint();
+        endpoint.waitFor();
+
+        InetSocketAddress address = (InetSocketAddress) endpoint.getAddress();
+        return new HttpHost("localhost", address.getPort(), "http");
+    }
+
+    @After
+    public void tearDown() {
+        if (server != null) {
+            server.shutdown(5, TimeUnit.SECONDS);
+        }
+    }
+}
diff --git a/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/InstrumentedHttpClientsTest.java b/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/InstrumentedHttpClientsTest.java
index ca53b51..f0decf3 100644
--- a/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/InstrumentedHttpClientsTest.java
+++ b/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/InstrumentedHttpClientsTest.java
@@ -4,47 +4,54 @@ import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.MetricRegistryListener;
 import com.codahale.metrics.Timer;
 import com.codahale.metrics.httpclient.HttpClientMetricNameStrategy;
-import com.codahale.metrics.httpclient.InstrumentedHttpClients;
 import org.apache.http.HttpRequest;
-import org.apache.http.client.HttpClient;
+import org.apache.http.HttpHost;
 import org.apache.http.client.methods.HttpGet;
 import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
 import org.apache.http.nio.client.HttpAsyncClient;
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
-import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+
+@RunWith(MockitoJUnitRunner.class)
+public class InstrumentedHttpClientsTest extends HttpClientTestBase {
 
-public class InstrumentedHttpClientsTest {
-    private final HttpClientMetricNameStrategy metricNameStrategy =
-            mock(HttpClientMetricNameStrategy.class);
-    private final MetricRegistryListener registryListener =
-            mock(MetricRegistryListener.class);
     private final MetricRegistry metricRegistry = new MetricRegistry();
-    
-    private  HttpAsyncClient hac;
 
-    @Before
-    public void setUp() throws Exception {
-        CloseableHttpAsyncClient chac = new InstrumentedNHttpClientBuilder(metricRegistry, metricNameStrategy).build();
-        chac.start();
-        hac = chac;
-        metricRegistry.addListener(registryListener);
-    }
+
+    private HttpAsyncClient asyncHttpClient;
+    @Mock
+    private HttpClientMetricNameStrategy metricNameStrategy;
+    @Mock
+    private MetricRegistryListener registryListener;
 
     @Test
     public void registersExpectedMetricsGivenNameStrategy() throws Exception {
-        final HttpGet get = new HttpGet("http://example.com?q=anything");
-        final String metricName = "some.made.up.metric.name";
+        HttpHost host = startServerWithGlobalRequestHandler(STATUS_OK);
+        final HttpGet get = new HttpGet("/q=anything");
+        final String metricName = MetricRegistry.name("some.made.up.metric.name");
 
-        when(metricNameStrategy.getNameFor(anyString(), any(HttpRequest.class)))
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class)))
                 .thenReturn(metricName);
 
-        hac.execute(get,null).get();
+        asyncHttpClient.execute(host, get, null).get();
 
         verify(registryListener).onTimerAdded(eq(metricName), any(Timer.class));
     }
+
+    @Before
+    public void setUp() throws Exception {
+        CloseableHttpAsyncClient chac = new InstrumentedNHttpClientBuilder(metricRegistry, metricNameStrategy).build();
+        chac.start();
+        asyncHttpClient = chac;
+        metricRegistry.addListener(registryListener);
+    }
 }
diff --git a/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/InstrumentedHttpClientsTimerTest.java b/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/InstrumentedHttpClientsTimerTest.java
new file mode 100644
index 0000000..5a3063b
--- /dev/null
+++ b/metrics-httpasyncclient/src/test/java/com/codahale/metrics/httpasyncclient/InstrumentedHttpClientsTimerTest.java
@@ -0,0 +1,134 @@
+package com.codahale.metrics.httpasyncclient;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.httpclient.HttpClientMetricNameStrategy;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.concurrent.FutureCallback;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.apache.http.nio.client.HttpAsyncClient;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+@Ignore("The tests are flaky")
+public class InstrumentedHttpClientsTimerTest extends HttpClientTestBase {
+
+    private HttpAsyncClient asyncHttpClient;
+
+    @Mock
+    private Timer.Context context;
+
+    @Mock
+    private MetricRegistry metricRegistry;
+
+
+    @Before
+    public void setUp() throws Exception {
+        CloseableHttpAsyncClient chac = new InstrumentedNHttpClientBuilder(metricRegistry,
+                mock(HttpClientMetricNameStrategy.class)).build();
+        chac.start();
+        asyncHttpClient = chac;
+
+        Timer timer = mock(Timer.class);
+        when(timer.time()).thenReturn(context);
+        when(metricRegistry.timer(any())).thenReturn(timer);
+    }
+
+    @Test
+    public void timerIsStoppedCorrectly() throws Exception {
+        HttpHost host = startServerWithGlobalRequestHandler(STATUS_OK);
+        HttpGet get = new HttpGet("/?q=anything");
+
+        // Timer hasn't been stopped prior to executing the request
+        verify(context, never()).stop();
+
+        Future<HttpResponse> responseFuture = asyncHttpClient.execute(host, get, null);
+
+        // Timer should still be running
+        verify(context, never()).stop();
+
+        responseFuture.get(20, TimeUnit.SECONDS);
+
+        // After the computation is complete timer must be stopped
+        // Materialzing the future and calling the future callback is not an atomic operation so
+        // we need to wait for callback to succeed
+        verify(context, timeout(200).times(1)).stop();
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void timerIsStoppedCorrectlyWithProvidedFutureCallbackCompleted() throws Exception {
+        HttpHost host = startServerWithGlobalRequestHandler(STATUS_OK);
+        HttpGet get = new HttpGet("/?q=something");
+
+        FutureCallback<HttpResponse> futureCallback = mock(FutureCallback.class);
+
+        // Timer hasn't been stopped prior to executing the request
+        verify(context, never()).stop();
+
+        Future<HttpResponse> responseFuture = asyncHttpClient.execute(host, get, futureCallback);
+
+        // Timer should still be running
+        verify(context, never()).stop();
+
+        responseFuture.get(20, TimeUnit.SECONDS);
+
+        // Callback must have been called
+        assertThat(responseFuture.isDone()).isTrue();
+        // After the computation is complete timer must be stopped
+        // Materialzing the future and calling the future callback is not an atomic operation so
+        // we need to wait for callback to succeed
+        verify(futureCallback, timeout(200).times(1)).completed(any(HttpResponse.class));
+        verify(context, timeout(200).times(1)).stop();
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void timerIsStoppedCorrectlyWithProvidedFutureCallbackFailed() throws Exception {
+        // There should be nothing listening on this port
+        HttpHost host = HttpHost.create(String.format("http://127.0.0.1:%d", findAvailableLocalPort()));
+        HttpGet get = new HttpGet("/?q=something");
+
+        FutureCallback<HttpResponse> futureCallback = mock(FutureCallback.class);
+
+        // Timer hasn't been stopped prior to executing the request
+        verify(context, never()).stop();
+
+        Future<HttpResponse> responseFuture = asyncHttpClient.execute(host, get, futureCallback);
+
+        // Timer should still be running
+        verify(context, never()).stop();
+
+        try {
+            responseFuture.get(20, TimeUnit.SECONDS);
+            fail("This should fail as the client should not be able to connect");
+        } catch (Exception e) {
+            // Ignore
+        }
+        // After the computation is complete timer must be stopped
+        // Materialzing the future and calling the future callback is not an atomic operation so
+        // we need to wait for callback to succeed
+        verify(futureCallback, timeout(200).times(1)).failed(any(Exception.class));
+        verify(context, timeout(200).times(1)).stop();
+    }
+
+}
diff --git a/metrics-httpclient/pom.xml b/metrics-httpclient/pom.xml
index de14565..e3e565c 100644
--- a/metrics-httpclient/pom.xml
+++ b/metrics-httpclient/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-httpclient</artifactId>
@@ -16,16 +16,70 @@
         durations and rates, and other useful information.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.httpclient</javaModuleName>
+        <http-client.version>4.5.14</http-client.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.httpcomponents</groupId>
+                <artifactId>httpcore</artifactId>
+                <version>4.4.16</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpcore</artifactId>
         </dependency>
         <dependency>
             <groupId>org.apache.httpcomponents</groupId>
             <artifactId>httpclient</artifactId>
-            <version>4.5.2</version>
+            <version>${http-client.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 </project>
diff --git a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategies.java b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategies.java
index 4b3b928..829011c 100644
--- a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategies.java
+++ b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategies.java
@@ -14,41 +14,37 @@ import static com.codahale.metrics.MetricRegistry.name;
 public class HttpClientMetricNameStrategies {
 
     public static final HttpClientMetricNameStrategy METHOD_ONLY =
-            new HttpClientMetricNameStrategy() {
-                @Override
-                public String getNameFor(String name, HttpRequest request) {
-                    return name(HttpClient.class,
-                                name,
-                                methodNameString(request));
-                }
-            };
+        (name, request) -> name(HttpClient.class,
+            name,
+            methodNameString(request));
 
     public static final HttpClientMetricNameStrategy HOST_AND_METHOD =
-            new HttpClientMetricNameStrategy() {
-                @Override
-                public String getNameFor(String name, HttpRequest request) {
-                    return name(HttpClient.class,
-                                name,
-                                requestURI(request).getHost(),
-                                methodNameString(request));
-                }
-            };
+        (name, request) -> name(HttpClient.class,
+            name,
+            requestURI(request).getHost(),
+            methodNameString(request));
+
+    public static final HttpClientMetricNameStrategy PATH_AND_METHOD =
+        (name, request) -> {
+            final URIBuilder url = new URIBuilder(requestURI(request));
+            return name(HttpClient.class,
+                name,
+                url.getPath(),
+                methodNameString(request));
+        };
 
     public static final HttpClientMetricNameStrategy QUERYLESS_URL_AND_METHOD =
-            new HttpClientMetricNameStrategy() {
-                @Override
-                public String getNameFor(String name, HttpRequest request) {
-                    try {
-                        final URIBuilder url = new URIBuilder(requestURI(request));
-                        return name(HttpClient.class,
-                                    name,
-                                    url.removeQuery().build().toString(),
-                                    methodNameString(request));
-                    } catch (URISyntaxException e) {
-                        throw new IllegalArgumentException(e);
-                    }
-                }
-            };
+        (name, request) -> {
+            try {
+                final URIBuilder url = new URIBuilder(requestURI(request));
+                return name(HttpClient.class,
+                    name,
+                    url.removeQuery().build().toString(),
+                    methodNameString(request));
+            } catch (URISyntaxException e) {
+                throw new IllegalArgumentException(e);
+            }
+        };
 
     private static String methodNameString(HttpRequest request) {
         return request.getRequestLine().getMethod().toLowerCase() + "-requests";
@@ -60,6 +56,6 @@ public class HttpClientMetricNameStrategies {
 
         return (request instanceof HttpUriRequest) ?
             ((HttpUriRequest) request).getURI() :
-                URI.create(request.getRequestLine().getUri());
+            URI.create(request.getRequestLine().getUri());
     }
 }
diff --git a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategy.java b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategy.java
index a6400c2..08538e9 100644
--- a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategy.java
+++ b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategy.java
@@ -1,7 +1,17 @@
 package com.codahale.metrics.httpclient;
 
+import com.codahale.metrics.MetricRegistry;
 import org.apache.http.HttpRequest;
+import org.apache.http.client.HttpClient;
 
+@FunctionalInterface
 public interface HttpClientMetricNameStrategy {
+
     String getNameFor(String name, HttpRequest request);
+
+    default String getNameFor(String name, Exception exception) {
+        return MetricRegistry.name(HttpClient.class,
+                name,
+                exception.getClass().getSimpleName());
+    }
 }
diff --git a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManager.java b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManager.java
index 0503780..89d3978 100644
--- a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManager.java
+++ b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManager.java
@@ -1,14 +1,19 @@
 package com.codahale.metrics.httpclient;
 
-import com.codahale.metrics.Gauge;
 import com.codahale.metrics.MetricRegistry;
 import org.apache.http.config.Registry;
 import org.apache.http.config.RegistryBuilder;
-import org.apache.http.conn.*;
+import org.apache.http.conn.DnsResolver;
+import org.apache.http.conn.HttpClientConnectionManager;
+import org.apache.http.conn.HttpClientConnectionOperator;
+import org.apache.http.conn.HttpConnectionFactory;
+import org.apache.http.conn.ManagedHttpClientConnection;
+import org.apache.http.conn.SchemePortResolver;
 import org.apache.http.conn.routing.HttpRoute;
 import org.apache.http.conn.socket.ConnectionSocketFactory;
 import org.apache.http.conn.socket.PlainConnectionSocketFactory;
 import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.impl.conn.DefaultHttpClientConnectionOperator;
 import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
 import org.apache.http.impl.conn.SystemDefaultDnsResolver;
 
@@ -24,24 +29,36 @@ public class InstrumentedHttpClientConnectionManager extends PoolingHttpClientCo
 
     protected static Registry<ConnectionSocketFactory> getDefaultRegistry() {
         return RegistryBuilder.<ConnectionSocketFactory>create()
-                .register("http", PlainConnectionSocketFactory.getSocketFactory())
-                .register("https", SSLConnectionSocketFactory.getSocketFactory())
-                .build();
+            .register("http", PlainConnectionSocketFactory.getSocketFactory())
+            .register("https", SSLConnectionSocketFactory.getSocketFactory())
+            .build();
     }
 
     private final MetricRegistry metricsRegistry;
     private final String name;
 
+    /**
+     * @deprecated Use {@link #builder(MetricRegistry)} instead.
+     */
+    @Deprecated
     public InstrumentedHttpClientConnectionManager(MetricRegistry metricRegistry) {
         this(metricRegistry, getDefaultRegistry());
     }
 
+    /**
+     * @deprecated Use {@link #builder(MetricRegistry)} instead.
+     */
+    @Deprecated
     public InstrumentedHttpClientConnectionManager(MetricRegistry metricsRegistry,
                                                    Registry<ConnectionSocketFactory> socketFactoryRegistry) {
         this(metricsRegistry, socketFactoryRegistry, -1, TimeUnit.MILLISECONDS);
     }
 
 
+    /**
+     * @deprecated Use {@link #builder(MetricRegistry)} instead.
+     */
+    @Deprecated
     public InstrumentedHttpClientConnectionManager(MetricRegistry metricsRegistry,
                                                    Registry<ConnectionSocketFactory> socketFactoryRegistry,
                                                    long connTTL,
@@ -49,49 +66,55 @@ public class InstrumentedHttpClientConnectionManager extends PoolingHttpClientCo
         this(metricsRegistry, socketFactoryRegistry, null, null, SystemDefaultDnsResolver.INSTANCE, connTTL, connTTLTimeUnit, null);
     }
 
+
+    /**
+     * @deprecated Use {@link #builder(MetricRegistry)} instead.
+     */
+    @Deprecated
     public InstrumentedHttpClientConnectionManager(MetricRegistry metricsRegistry,
                                                    Registry<ConnectionSocketFactory> socketFactoryRegistry,
-                                                   HttpConnectionFactory<HttpRoute,ManagedHttpClientConnection> connFactory,
+                                                   HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection>
+                                                           connFactory,
                                                    SchemePortResolver schemePortResolver,
                                                    DnsResolver dnsResolver,
                                                    long connTTL,
                                                    TimeUnit connTTLTimeUnit,
                                                    String name) {
-        super(socketFactoryRegistry, connFactory, schemePortResolver, dnsResolver, connTTL, connTTLTimeUnit);
+        this(metricsRegistry,
+             new DefaultHttpClientConnectionOperator(socketFactoryRegistry, schemePortResolver, dnsResolver),
+             connFactory,
+             connTTL,
+             connTTLTimeUnit,
+             name);
+    }
+
+    /**
+     * @deprecated Use {@link #builder(MetricRegistry)} instead.
+     */
+    @Deprecated
+    public InstrumentedHttpClientConnectionManager(MetricRegistry metricsRegistry,
+                                                   HttpClientConnectionOperator httpClientConnectionOperator,
+                                                   HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection>
+                                                           connFactory,
+                                                   long connTTL,
+                                                   TimeUnit connTTLTimeUnit,
+                                                   String name) {
+        super(httpClientConnectionOperator, connFactory, connTTL, connTTLTimeUnit);
         this.metricsRegistry = metricsRegistry;
         this.name = name;
-        metricsRegistry.register(name(HttpClientConnectionManager.class, name, "available-connections"),
-                                 new Gauge<Integer>() {
-                                     @Override
-                                     public Integer getValue() {
-                                         // this acquires a lock on the connection pool; remove if contention sucks
-                                         return getTotalStats().getAvailable();
-                                     }
-                                 });
-        metricsRegistry.register(name(HttpClientConnectionManager.class, name, "leased-connections"),
-                                 new Gauge<Integer>() {
-                                     @Override
-                                     public Integer getValue() {
-                                         // this acquires a lock on the connection pool; remove if contention sucks
-                                         return getTotalStats().getLeased();
-                                     }
-                                 });
-        metricsRegistry.register(name(HttpClientConnectionManager.class, name, "max-connections"),
-                                 new Gauge<Integer>() {
-                                     @Override
-                                     public Integer getValue() {
-                                         // this acquires a lock on the connection pool; remove if contention sucks
-                                         return getTotalStats().getMax();
-                                     }
-                                 });
-        metricsRegistry.register(name(HttpClientConnectionManager.class, name, "pending-connections"),
-                                 new Gauge<Integer>() {
-                                     @Override
-                                     public Integer getValue() {
-                                         // this acquires a lock on the connection pool; remove if contention sucks
-                                         return getTotalStats().getPending();
-                                     }
-                                 });
+
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricsRegistry.registerGauge(name(HttpClientConnectionManager.class, name, "available-connections"),
+                () -> getTotalStats().getAvailable());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricsRegistry.registerGauge(name(HttpClientConnectionManager.class, name, "leased-connections"),
+                () -> getTotalStats().getLeased());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricsRegistry.registerGauge(name(HttpClientConnectionManager.class, name, "max-connections"),
+                () -> getTotalStats().getMax());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricsRegistry.registerGauge(name(HttpClientConnectionManager.class, name, "pending-connections"),
+                () -> getTotalStats().getPending());
     }
 
     @Override
@@ -102,4 +125,76 @@ public class InstrumentedHttpClientConnectionManager extends PoolingHttpClientCo
         metricsRegistry.remove(name(HttpClientConnectionManager.class, name, "max-connections"));
         metricsRegistry.remove(name(HttpClientConnectionManager.class, name, "pending-connections"));
     }
+
+    public static Builder builder(MetricRegistry metricsRegistry) {
+        return new Builder().metricsRegistry(metricsRegistry);
+    }
+
+    public static class Builder {
+        private MetricRegistry metricsRegistry;
+        private HttpClientConnectionOperator httpClientConnectionOperator;
+        private Registry<ConnectionSocketFactory> socketFactoryRegistry = getDefaultRegistry();
+        private HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection> connFactory;
+        private SchemePortResolver schemePortResolver;
+        private DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE;
+        private long connTTL = -1;
+        private TimeUnit connTTLTimeUnit = TimeUnit.MILLISECONDS;
+        private String name;
+
+        Builder() {
+        }
+
+        public Builder metricsRegistry(MetricRegistry metricsRegistry) {
+            this.metricsRegistry = metricsRegistry;
+            return this;
+        }
+
+        public Builder socketFactoryRegistry(Registry<ConnectionSocketFactory> socketFactoryRegistry) {
+            this.socketFactoryRegistry = socketFactoryRegistry;
+            return this;
+        }
+
+        public Builder connFactory(HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection> connFactory) {
+            this.connFactory = connFactory;
+            return this;
+        }
+
+        public Builder schemePortResolver(SchemePortResolver schemePortResolver) {
+            this.schemePortResolver = schemePortResolver;
+            return this;
+        }
+
+        public Builder dnsResolver(DnsResolver dnsResolver) {
+            this.dnsResolver = dnsResolver;
+            return this;
+        }
+
+        public Builder connTTL(long connTTL) {
+            this.connTTL = connTTL;
+            return this;
+        }
+
+        public Builder connTTLTimeUnit(TimeUnit connTTLTimeUnit) {
+            this.connTTLTimeUnit = connTTLTimeUnit;
+            return this;
+        }
+
+        public Builder httpClientConnectionOperator(HttpClientConnectionOperator httpClientConnectionOperator) {
+            this.httpClientConnectionOperator = httpClientConnectionOperator;
+            return this;
+        }
+
+        public Builder name(final String name) {
+            this.name = name;
+            return this;
+        }
+
+        public InstrumentedHttpClientConnectionManager build() {
+            if (httpClientConnectionOperator == null) {
+                httpClientConnectionOperator = new DefaultHttpClientConnectionOperator(socketFactoryRegistry, schemePortResolver, dnsResolver);
+            }
+            return new InstrumentedHttpClientConnectionManager(metricsRegistry, httpClientConnectionOperator, connFactory, connTTL, connTTLTimeUnit, name);
+        }
+    }
+
 }
diff --git a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClients.java b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClients.java
index 30c6d7d..12c63a9 100644
--- a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClients.java
+++ b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpClients.java
@@ -28,8 +28,7 @@ public class InstrumentedHttpClients {
                                            HttpClientMetricNameStrategy metricNameStrategy) {
         return HttpClientBuilder.create()
                 .setRequestExecutor(new InstrumentedHttpRequestExecutor(metricRegistry, metricNameStrategy))
-                .setConnectionManager(new InstrumentedHttpClientConnectionManager(metricRegistry));
+                .setConnectionManager(InstrumentedHttpClientConnectionManager.builder(metricRegistry).build());
     }
 
-
 }
diff --git a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpRequestExecutor.java b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpRequestExecutor.java
index dedaccc..4acf024 100644
--- a/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpRequestExecutor.java
+++ b/metrics-httpclient/src/main/java/com/codahale/metrics/httpclient/InstrumentedHttpRequestExecutor.java
@@ -9,6 +9,8 @@ import org.apache.http.HttpResponse;
 import org.apache.http.protocol.HttpContext;
 import org.apache.http.protocol.HttpRequestExecutor;
 
+import com.codahale.metrics.Meter;
+
 import java.io.IOException;
 
 public class InstrumentedHttpRequestExecutor extends HttpRequestExecutor {
@@ -42,6 +44,9 @@ public class InstrumentedHttpRequestExecutor extends HttpRequestExecutor {
         final Timer.Context timerContext = timer(request).time();
         try {
             return super.execute(request, conn, context);
+        } catch (HttpException | IOException e) {
+            meter(e).mark();
+            throw e;
         } finally {
             timerContext.stop();
         }
@@ -50,4 +55,8 @@ public class InstrumentedHttpRequestExecutor extends HttpRequestExecutor {
     private Timer timer(HttpRequest request) {
         return registry.timer(metricNameStrategy.getNameFor(name, request));
     }
+
+    private Meter meter(Exception e) {
+        return registry.meter(metricNameStrategy.getNameFor(name, e));
+    }
 }
diff --git a/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategiesTest.java b/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategiesTest.java
index 97942ae..5015b75 100644
--- a/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategiesTest.java
+++ b/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/HttpClientMetricNameStrategiesTest.java
@@ -11,72 +11,98 @@ import org.junit.Test;
 import java.net.URI;
 import java.net.URISyntaxException;
 
-import static com.codahale.metrics.httpclient.HttpClientMetricNameStrategies.*;
-import static org.hamcrest.CoreMatchers.is;
-import static org.junit.Assert.assertThat;
+import static com.codahale.metrics.httpclient.HttpClientMetricNameStrategies.HOST_AND_METHOD;
+import static com.codahale.metrics.httpclient.HttpClientMetricNameStrategies.METHOD_ONLY;
+import static com.codahale.metrics.httpclient.HttpClientMetricNameStrategies.PATH_AND_METHOD;
+import static com.codahale.metrics.httpclient.HttpClientMetricNameStrategies.QUERYLESS_URL_AND_METHOD;
+import static org.assertj.core.api.Assertions.assertThat;
 
 public class HttpClientMetricNameStrategiesTest {
 
     @Test
     public void methodOnlyWithName() {
-        assertThat(METHOD_ONLY.getNameFor("some-service", new HttpGet("/whatever")),
-                   is("org.apache.http.client.HttpClient.some-service.get-requests"));
+        assertThat(METHOD_ONLY.getNameFor("some-service", new HttpGet("/whatever")))
+                .isEqualTo("org.apache.http.client.HttpClient.some-service.get-requests");
     }
 
     @Test
     public void methodOnlyWithoutName() {
-        assertThat(METHOD_ONLY.getNameFor(null, new HttpGet("/whatever")),
-                is("org.apache.http.client.HttpClient.get-requests"));
+        assertThat(METHOD_ONLY.getNameFor(null, new HttpGet("/whatever")))
+                .isEqualTo("org.apache.http.client.HttpClient.get-requests");
     }
 
     @Test
     public void hostAndMethodWithName() {
-        assertThat(HOST_AND_METHOD.getNameFor("some-service", new HttpPost("http://my.host.com/whatever")),
-                   is("org.apache.http.client.HttpClient.some-service.my.host.com.post-requests"));
+        assertThat(HOST_AND_METHOD.getNameFor("some-service", new HttpPost("http://my.host.com/whatever")))
+                .isEqualTo("org.apache.http.client.HttpClient.some-service.my.host.com.post-requests");
     }
 
     @Test
     public void hostAndMethodWithoutName() {
-        assertThat(HOST_AND_METHOD.getNameFor(null, new HttpPost("http://my.host.com/whatever")),
-                is("org.apache.http.client.HttpClient.my.host.com.post-requests"));
+        assertThat(HOST_AND_METHOD.getNameFor(null, new HttpPost("http://my.host.com/whatever")))
+                .isEqualTo("org.apache.http.client.HttpClient.my.host.com.post-requests");
     }
 
     @Test
     public void hostAndMethodWithNameInWrappedRequest() throws URISyntaxException {
         HttpRequest request = rewriteRequestURI(new HttpPost("http://my.host.com/whatever"));
 
-        assertThat(HOST_AND_METHOD.getNameFor("some-service", request),
-                is("org.apache.http.client.HttpClient.some-service.my.host.com.post-requests"));
+        assertThat(HOST_AND_METHOD.getNameFor("some-service", request))
+                .isEqualTo("org.apache.http.client.HttpClient.some-service.my.host.com.post-requests");
     }
 
     @Test
     public void hostAndMethodWithoutNameInWrappedRequest() throws URISyntaxException {
         HttpRequest request = rewriteRequestURI(new HttpPost("http://my.host.com/whatever"));
 
-        assertThat(HOST_AND_METHOD.getNameFor(null, request),
-                is("org.apache.http.client.HttpClient.my.host.com.post-requests"));
+        assertThat(HOST_AND_METHOD.getNameFor(null, request))
+                .isEqualTo("org.apache.http.client.HttpClient.my.host.com.post-requests");
+    }
+
+    @Test
+    public void pathAndMethodWithName() {
+        assertThat(PATH_AND_METHOD.getNameFor("some-service", new HttpPost("http://my.host.com/whatever/happens")))
+                .isEqualTo("org.apache.http.client.HttpClient.some-service./whatever/happens.post-requests");
+    }
+
+    @Test
+    public void pathAndMethodWithoutName() {
+        assertThat(PATH_AND_METHOD.getNameFor(null, new HttpPost("http://my.host.com/whatever/happens")))
+                .isEqualTo("org.apache.http.client.HttpClient./whatever/happens.post-requests");
+    }
+
+    @Test
+    public void pathAndMethodWithNameInWrappedRequest() throws URISyntaxException {
+        HttpRequest request = rewriteRequestURI(new HttpPost("http://my.host.com/whatever/happens"));
+        assertThat(PATH_AND_METHOD.getNameFor("some-service", request))
+                .isEqualTo("org.apache.http.client.HttpClient.some-service./whatever/happens.post-requests");
+    }
+
+    @Test
+    public void pathAndMethodWithoutNameInWrappedRequest() throws URISyntaxException {
+        HttpRequest request = rewriteRequestURI(new HttpPost("http://my.host.com/whatever/happens"));
+        assertThat(PATH_AND_METHOD.getNameFor(null, request))
+                .isEqualTo("org.apache.http.client.HttpClient./whatever/happens.post-requests");
     }
 
     @Test
     public void querylessUrlAndMethodWithName() {
         assertThat(QUERYLESS_URL_AND_METHOD.getNameFor(
                 "some-service",
-                new HttpPut("https://thing.com:8090/my/path?ignore=this&and=this")),
-                is("org.apache.http.client.HttpClient.some-service.https://thing.com:8090/my/path.put-requests"));
+                new HttpPut("https://thing.com:8090/my/path?ignore=this&and=this")))
+                .isEqualTo("org.apache.http.client.HttpClient.some-service.https://thing.com:8090/my/path.put-requests");
     }
 
     @Test
     public void querylessUrlAndMethodWithNameInWrappedRequest() throws URISyntaxException {
         HttpRequest request = rewriteRequestURI(new HttpPut("https://thing.com:8090/my/path?ignore=this&and=this"));
-        assertThat(QUERYLESS_URL_AND_METHOD.getNameFor(
-                "some-service",
-                request),
-                is("org.apache.http.client.HttpClient.some-service.https://thing.com:8090/my/path.put-requests"));
+        assertThat(QUERYLESS_URL_AND_METHOD.getNameFor("some-service", request))
+                .isEqualTo("org.apache.http.client.HttpClient.some-service.https://thing.com:8090/my/path.put-requests");
     }
 
     private static HttpRequest rewriteRequestURI(HttpRequest request) throws URISyntaxException {
         HttpRequestWrapper wrapper = HttpRequestWrapper.wrap(request);
-        URI uri = URIUtils.rewriteURI(wrapper.getURI(), null, true);
+        URI uri = URIUtils.rewriteURI(wrapper.getURI(), null, URIUtils.DROP_FRAGMENT);
         wrapper.setURI(uri);
 
         return wrapper;
diff --git a/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManagerTest.java b/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManagerTest.java
index a0ca46d..662fa1a 100644
--- a/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManagerTest.java
+++ b/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientConnectionManagerTest.java
@@ -1,9 +1,15 @@
 package com.codahale.metrics.httpclient;
 
 import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.Timer;
 import org.junit.Assert;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import static junit.framework.TestCase.assertTrue;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.ArgumentMatchers.any;
 
 
 public class InstrumentedHttpClientConnectionManagerTest {
@@ -11,13 +17,33 @@ public class InstrumentedHttpClientConnectionManagerTest {
 
     @Test
     public void shouldRemoveGauges() {
-       final InstrumentedHttpClientConnectionManager instrumentedHttpClientConnectionManager = new InstrumentedHttpClientConnectionManager(metricRegistry);
-        Assert.assertEquals(4, metricRegistry.getGauges().size());
+        final InstrumentedHttpClientConnectionManager instrumentedHttpClientConnectionManager = InstrumentedHttpClientConnectionManager.builder(metricRegistry).build();
+        assertThat(metricRegistry.getGauges().entrySet().stream()
+                .map(e -> entry(e.getKey(), e.getValue().getValue())))
+                .containsOnly(entry("org.apache.http.conn.HttpClientConnectionManager.available-connections", 0),
+                        entry("org.apache.http.conn.HttpClientConnectionManager.leased-connections", 0),
+                        entry("org.apache.http.conn.HttpClientConnectionManager.max-connections", 20),
+                        entry("org.apache.http.conn.HttpClientConnectionManager.pending-connections", 0));
 
         instrumentedHttpClientConnectionManager.close();
         Assert.assertEquals(0, metricRegistry.getGauges().size());
 
         // should be able to create another one with the same name ("")
-        new InstrumentedHttpClientConnectionManager(metricRegistry);
+        InstrumentedHttpClientConnectionManager.builder(metricRegistry).build().close();
+    }
+
+    @Test
+    public void configurableViaBuilder() {
+        final MetricRegistry registry = Mockito.mock(MetricRegistry.class);
+
+        InstrumentedHttpClientConnectionManager.builder(registry)
+                .name("some-name")
+                .name("some-other-name")
+                .build()
+                .close();
+
+        ArgumentCaptor<String> argumentCaptor = ArgumentCaptor.forClass(String.class);
+        Mockito.verify(registry, Mockito.atLeast(1)).registerGauge(argumentCaptor.capture(), any());
+        assertTrue(argumentCaptor.getValue().contains("some-other-name"));
     }
 }
diff --git a/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientsTest.java b/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientsTest.java
index b77bc01..9d6c147 100644
--- a/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientsTest.java
+++ b/metrics-httpclient/src/test/java/com/codahale/metrics/httpclient/InstrumentedHttpClientsTest.java
@@ -3,16 +3,24 @@ package com.codahale.metrics.httpclient;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.MetricRegistryListener;
 import com.codahale.metrics.Timer;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
 import org.apache.http.HttpRequest;
+import org.apache.http.NoHttpResponseException;
 import org.apache.http.client.HttpClient;
 import org.apache.http.client.methods.HttpGet;
 import org.junit.Before;
 import org.junit.Test;
 
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
-import static org.mockito.Mockito.*;
+import java.net.InetSocketAddress;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 public class InstrumentedHttpClientsTest {
     private final HttpClientMetricNameStrategy metricNameStrategy =
@@ -21,10 +29,10 @@ public class InstrumentedHttpClientsTest {
             mock(MetricRegistryListener.class);
     private final MetricRegistry metricRegistry = new MetricRegistry();
     private final HttpClient client =
-            InstrumentedHttpClients.createDefault(metricRegistry, metricNameStrategy);
+            InstrumentedHttpClients.custom(metricRegistry, metricNameStrategy).disableAutomaticRetries().build();
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         metricRegistry.addListener(registryListener);
     }
 
@@ -33,11 +41,37 @@ public class InstrumentedHttpClientsTest {
         final HttpGet get = new HttpGet("http://example.com?q=anything");
         final String metricName = "some.made.up.metric.name";
 
-        when(metricNameStrategy.getNameFor(anyString(), any(HttpRequest.class)))
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class)))
                 .thenReturn(metricName);
 
         client.execute(get);
 
         verify(registryListener).onTimerAdded(eq(metricName), any(Timer.class));
     }
+
+    @Test
+    public void registersExpectedExceptionMetrics() throws Exception {
+        HttpServer httpServer = HttpServer.create(new InetSocketAddress(0), 0);
+
+        final HttpGet get = new HttpGet("http://localhost:" + httpServer.getAddress().getPort() + "/");
+        final String requestMetricName = "request";
+        final String exceptionMetricName = "exception";
+
+        httpServer.createContext("/", HttpExchange::close);
+        httpServer.start();
+
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class)))
+                .thenReturn(requestMetricName);
+        when(metricNameStrategy.getNameFor(any(), any(Exception.class)))
+                .thenReturn(exceptionMetricName);
+
+        try {
+            client.execute(get);
+            fail();
+        } catch (NoHttpResponseException expected) {
+            assertThat(metricRegistry.getMeters()).containsKey("exception");
+        } finally {
+            httpServer.stop(0);
+        }
+    }
 }
diff --git a/metrics-httpclient5/pom.xml b/metrics-httpclient5/pom.xml
new file mode 100644
index 0000000..bbb1ec1
--- /dev/null
+++ b/metrics-httpclient5/pom.xml
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-httpclient5</artifactId>
+    <name>Metrics Integration for Apache HttpClient 5.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        An Apache HttpClient 5.x wrapper providing Metrics instrumentation of connection pools, request
+        durations and rates, and other useful information.
+    </description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.httpclient</javaModuleName>
+        <http-client.version>5.3.1</http-client.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.httpcomponents.client5</groupId>
+                <artifactId>httpclient5</artifactId>
+                <version>${http-client.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.httpcomponents.core5</groupId>
+                <artifactId>httpcore5</artifactId>
+                <version>5.2.4</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents.client5</groupId>
+            <artifactId>httpclient5</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents.core5</groupId>
+            <artifactId>httpcore5</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <version>4.2.0</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategies.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategies.java
new file mode 100644
index 0000000..a7911a2
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategies.java
@@ -0,0 +1,48 @@
+package com.codahale.metrics.httpclient5;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.net.URIBuilder;
+
+import java.net.URISyntaxException;
+import java.util.Locale;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+public class HttpClientMetricNameStrategies {
+
+    public static final HttpClientMetricNameStrategy METHOD_ONLY =
+            (name, request) -> name(HttpClient.class,
+                    name,
+                    methodNameString(request));
+
+    public static final HttpClientMetricNameStrategy HOST_AND_METHOD =
+            (name, request) -> {
+                try {
+                    return name(HttpClient.class,
+                            name,
+                            request.getUri().getHost(),
+                            methodNameString(request));
+                } catch (URISyntaxException e) {
+                    throw new IllegalArgumentException(e);
+                }
+            };
+
+    public static final HttpClientMetricNameStrategy QUERYLESS_URL_AND_METHOD =
+            (name, request) -> {
+                try {
+                    final URIBuilder url = new URIBuilder(request.getUri());
+                    return name(HttpClient.class,
+                            name,
+                            url.removeQuery().build().toString(),
+                            methodNameString(request));
+                } catch (URISyntaxException e) {
+                    throw new IllegalArgumentException(e);
+                }
+            };
+
+    private static String methodNameString(HttpRequest request) {
+        return request.getMethod().toLowerCase(Locale.ROOT) + "-requests";
+    }
+
+}
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategy.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategy.java
new file mode 100644
index 0000000..2077ef0
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategy.java
@@ -0,0 +1,17 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.HttpRequest;
+
+@FunctionalInterface
+public interface HttpClientMetricNameStrategy {
+
+    String getNameFor(String name, HttpRequest request);
+
+    default String getNameFor(String name, Exception exception) {
+        return MetricRegistry.name(HttpClient.class,
+                name,
+                exception.getClass().getSimpleName());
+    }
+}
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedAsyncClientConnectionManager.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedAsyncClientConnectionManager.java
new file mode 100644
index 0000000..b778c54
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedAsyncClientConnectionManager.java
@@ -0,0 +1,155 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import org.apache.hc.client5.http.DnsResolver;
+import org.apache.hc.client5.http.SchemePortResolver;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.nio.AsyncClientConnectionManager;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.config.Lookup;
+import org.apache.hc.core5.http.config.Registry;
+import org.apache.hc.core5.http.config.RegistryBuilder;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.pool.PoolConcurrencyPolicy;
+import org.apache.hc.core5.pool.PoolReusePolicy;
+import org.apache.hc.core5.util.TimeValue;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A {@link HttpClientConnectionManager} which monitors the number of open connections.
+ */
+public class InstrumentedAsyncClientConnectionManager extends PoolingAsyncClientConnectionManager {
+    private static final String METRICS_PREFIX = AsyncClientConnectionManager.class.getName();
+
+    protected static Registry<TlsStrategy> getDefaultTlsStrategy() {
+        return RegistryBuilder.<TlsStrategy>create()
+                .register(URIScheme.HTTPS.id, DefaultClientTlsStrategy.getDefault())
+                .build();
+    }
+
+    private final MetricRegistry metricsRegistry;
+    private final String name;
+
+    InstrumentedAsyncClientConnectionManager(final MetricRegistry metricRegistry,
+                                             final String name,
+                                             final Lookup<TlsStrategy> tlsStrategyLookup,
+                                             final PoolConcurrencyPolicy poolConcurrencyPolicy,
+                                             final PoolReusePolicy poolReusePolicy,
+                                             final TimeValue timeToLive,
+                                             final SchemePortResolver schemePortResolver,
+                                             final DnsResolver dnsResolver) {
+
+        super(tlsStrategyLookup, poolConcurrencyPolicy, poolReusePolicy, timeToLive, schemePortResolver, dnsResolver);
+        this.metricsRegistry = requireNonNull(metricRegistry, "metricRegistry");
+        this.name = name;
+
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "available-connections"),
+                () -> getTotalStats().getAvailable());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "leased-connections"),
+                () -> getTotalStats().getLeased());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "max-connections"),
+                () -> getTotalStats().getMax());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "pending-connections"),
+                () -> getTotalStats().getPending());
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void close() {
+        close(CloseMode.GRACEFUL);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void close(CloseMode closeMode) {
+        super.close(closeMode);
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "available-connections"));
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "leased-connections"));
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "max-connections"));
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "pending-connections"));
+    }
+
+    public static Builder builder(MetricRegistry metricsRegistry) {
+        return new Builder().metricsRegistry(metricsRegistry);
+    }
+
+    public static class Builder {
+        private MetricRegistry metricsRegistry;
+        private String name;
+        private Lookup<TlsStrategy> tlsStrategyLookup = getDefaultTlsStrategy();
+        private SchemePortResolver schemePortResolver;
+        private DnsResolver dnsResolver;
+        private PoolConcurrencyPolicy poolConcurrencyPolicy;
+        private PoolReusePolicy poolReusePolicy;
+        private TimeValue timeToLive = TimeValue.NEG_ONE_MILLISECOND;
+
+        Builder() {
+        }
+
+        public Builder metricsRegistry(MetricRegistry metricRegistry) {
+            this.metricsRegistry = requireNonNull(metricRegistry, "metricRegistry");
+            return this;
+        }
+
+        public Builder name(final String name) {
+            this.name = name;
+            return this;
+        }
+
+        public Builder schemePortResolver(SchemePortResolver schemePortResolver) {
+            this.schemePortResolver = schemePortResolver;
+            return this;
+        }
+
+        public Builder dnsResolver(DnsResolver dnsResolver) {
+            this.dnsResolver = dnsResolver;
+            return this;
+        }
+
+        public Builder timeToLive(TimeValue timeToLive) {
+            this.timeToLive = timeToLive;
+            return this;
+        }
+
+        public Builder tlsStrategyLookup(Lookup<TlsStrategy> tlsStrategyLookup) {
+            this.tlsStrategyLookup = tlsStrategyLookup;
+            return this;
+        }
+
+        public Builder poolConcurrencyPolicy(PoolConcurrencyPolicy poolConcurrencyPolicy) {
+            this.poolConcurrencyPolicy = poolConcurrencyPolicy;
+            return this;
+        }
+
+        public Builder poolReusePolicy(PoolReusePolicy poolReusePolicy) {
+            this.poolReusePolicy = poolReusePolicy;
+            return this;
+        }
+
+        public InstrumentedAsyncClientConnectionManager build() {
+            return new InstrumentedAsyncClientConnectionManager(
+                    metricsRegistry,
+                    name,
+                    tlsStrategyLookup,
+                    poolConcurrencyPolicy,
+                    poolReusePolicy,
+                    timeToLive,
+                    schemePortResolver,
+                    dnsResolver);
+        }
+    }
+
+}
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedAsyncExecChainHandler.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedAsyncExecChainHandler.java
new file mode 100644
index 0000000..f99b228
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedAsyncExecChainHandler.java
@@ -0,0 +1,99 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecChainHandler;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+
+import java.io.IOException;
+
+import static java.util.Objects.requireNonNull;
+
+class InstrumentedAsyncExecChainHandler implements AsyncExecChainHandler {
+    private final MetricRegistry registry;
+    private final HttpClientMetricNameStrategy metricNameStrategy;
+    private final String name;
+
+    public InstrumentedAsyncExecChainHandler(MetricRegistry registry, HttpClientMetricNameStrategy metricNameStrategy) {
+        this(registry, metricNameStrategy, null);
+    }
+
+    public InstrumentedAsyncExecChainHandler(MetricRegistry registry,
+                                             HttpClientMetricNameStrategy metricNameStrategy,
+                                             String name) {
+        this.registry = requireNonNull(registry, "registry");
+        this.metricNameStrategy = requireNonNull(metricNameStrategy, "metricNameStrategy");
+        this.name = name;
+    }
+
+    @Override
+    public void execute(HttpRequest request,
+                        AsyncEntityProducer entityProducer,
+                        AsyncExecChain.Scope scope,
+                        AsyncExecChain chain,
+                        AsyncExecCallback asyncExecCallback) throws HttpException, IOException {
+        final InstrumentedAsyncExecCallback instrumentedAsyncExecCallback =
+                new InstrumentedAsyncExecCallback(registry, metricNameStrategy, name, asyncExecCallback, request);
+        chain.proceed(request, entityProducer, scope, instrumentedAsyncExecCallback);
+
+    }
+
+    final static class InstrumentedAsyncExecCallback implements AsyncExecCallback {
+        private final MetricRegistry registry;
+        private final HttpClientMetricNameStrategy metricNameStrategy;
+        private final String name;
+        private final AsyncExecCallback delegate;
+        private final Timer.Context timerContext;
+
+        public InstrumentedAsyncExecCallback(MetricRegistry registry,
+                                             HttpClientMetricNameStrategy metricNameStrategy,
+                                             String name,
+                                             AsyncExecCallback delegate,
+                                             HttpRequest request) {
+            this.registry = registry;
+            this.metricNameStrategy = metricNameStrategy;
+            this.name = name;
+            this.delegate = delegate;
+            this.timerContext = timer(request).time();
+        }
+
+        @Override
+        public AsyncDataConsumer handleResponse(HttpResponse response, EntityDetails entityDetails) throws HttpException, IOException {
+            return delegate.handleResponse(response, entityDetails);
+        }
+
+        @Override
+        public void handleInformationResponse(HttpResponse response) throws HttpException, IOException {
+            delegate.handleInformationResponse(response);
+        }
+
+        @Override
+        public void completed() {
+            delegate.completed();
+            timerContext.stop();
+        }
+
+        @Override
+        public void failed(Exception cause) {
+            delegate.failed(cause);
+            meter(cause).mark();
+            timerContext.stop();
+        }
+
+        private Timer timer(HttpRequest request) {
+            return registry.timer(metricNameStrategy.getNameFor(name, request));
+        }
+
+        private Meter meter(Exception e) {
+            return registry.meter(metricNameStrategy.getNameFor(name, e));
+        }
+    }
+}
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpAsyncClients.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpAsyncClients.java
new file mode 100644
index 0000000..0bda99d
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpAsyncClients.java
@@ -0,0 +1,43 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import org.apache.hc.client5.http.impl.ChainElement;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+import org.apache.hc.client5.http.nio.AsyncClientConnectionManager;
+
+import static com.codahale.metrics.httpclient5.HttpClientMetricNameStrategies.METHOD_ONLY;
+
+public class InstrumentedHttpAsyncClients {
+    private InstrumentedHttpAsyncClients() {
+        super();
+    }
+
+    public static CloseableHttpAsyncClient createDefault(MetricRegistry metricRegistry) {
+        return createDefault(metricRegistry, METHOD_ONLY);
+    }
+
+    public static CloseableHttpAsyncClient createDefault(MetricRegistry metricRegistry,
+                                                         HttpClientMetricNameStrategy metricNameStrategy) {
+        return custom(metricRegistry, metricNameStrategy).build();
+    }
+
+    public static HttpAsyncClientBuilder custom(MetricRegistry metricRegistry) {
+        return custom(metricRegistry, METHOD_ONLY);
+    }
+
+    public static HttpAsyncClientBuilder custom(MetricRegistry metricRegistry,
+                                                HttpClientMetricNameStrategy metricNameStrategy) {
+        return custom(metricRegistry, metricNameStrategy, InstrumentedAsyncClientConnectionManager.builder(metricRegistry).build());
+    }
+
+    public static HttpAsyncClientBuilder custom(MetricRegistry metricRegistry,
+                                                HttpClientMetricNameStrategy metricNameStrategy,
+                                                AsyncClientConnectionManager clientConnectionManager) {
+        return HttpAsyncClientBuilder.create()
+                .setConnectionManager(clientConnectionManager)
+                .addExecInterceptorBefore(ChainElement.CONNECT.name(), "dropwizard-metrics",
+                        new InstrumentedAsyncExecChainHandler(metricRegistry, metricNameStrategy));
+    }
+
+}
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientConnectionManager.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientConnectionManager.java
new file mode 100644
index 0000000..c98b97f
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientConnectionManager.java
@@ -0,0 +1,178 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import org.apache.hc.client5.http.DnsResolver;
+import org.apache.hc.client5.http.SchemePortResolver;
+import org.apache.hc.client5.http.impl.io.DefaultHttpClientConnectionOperator;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.io.HttpClientConnectionOperator;
+import org.apache.hc.client5.http.io.ManagedHttpClientConnection;
+import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
+import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.config.Registry;
+import org.apache.hc.core5.http.config.RegistryBuilder;
+import org.apache.hc.core5.http.io.HttpConnectionFactory;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.pool.PoolConcurrencyPolicy;
+import org.apache.hc.core5.pool.PoolReusePolicy;
+import org.apache.hc.core5.util.TimeValue;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A {@link HttpClientConnectionManager} which monitors the number of open connections.
+ */
+public class InstrumentedHttpClientConnectionManager extends PoolingHttpClientConnectionManager {
+    private static final String METRICS_PREFIX = HttpClientConnectionManager.class.getName();
+
+    protected static Registry<ConnectionSocketFactory> getDefaultRegistry() {
+        return RegistryBuilder.<ConnectionSocketFactory>create()
+                .register(URIScheme.HTTP.id, PlainConnectionSocketFactory.getSocketFactory())
+                .register(URIScheme.HTTPS.id, SSLConnectionSocketFactory.getSocketFactory())
+                .build();
+    }
+
+    private final MetricRegistry metricsRegistry;
+    private final String name;
+
+    InstrumentedHttpClientConnectionManager(final MetricRegistry metricRegistry,
+                                            final String name,
+                                            final HttpClientConnectionOperator httpClientConnectionOperator,
+                                            final PoolConcurrencyPolicy poolConcurrencyPolicy,
+                                            final PoolReusePolicy poolReusePolicy,
+                                            final TimeValue timeToLive,
+                                            final HttpConnectionFactory<ManagedHttpClientConnection> connFactory) {
+
+        super(httpClientConnectionOperator, poolConcurrencyPolicy, poolReusePolicy, timeToLive, connFactory);
+        this.metricsRegistry = requireNonNull(metricRegistry, "metricRegistry");
+        this.name = name;
+
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "available-connections"),
+                () -> {
+                    return getTotalStats().getAvailable();
+                });
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "leased-connections"),
+                () -> getTotalStats().getLeased());
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "max-connections"),
+                () -> getTotalStats().getMax()
+        );
+        // this acquires a lock on the connection pool; remove if contention sucks
+        metricRegistry.registerGauge(name(METRICS_PREFIX, name, "pending-connections"),
+                () -> getTotalStats().getPending());
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void close() {
+        close(CloseMode.GRACEFUL);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void close(CloseMode closeMode) {
+        super.close(closeMode);
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "available-connections"));
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "leased-connections"));
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "max-connections"));
+        metricsRegistry.remove(name(METRICS_PREFIX, name, "pending-connections"));
+    }
+
+    public static Builder builder(MetricRegistry metricsRegistry) {
+        return new Builder().metricsRegistry(metricsRegistry);
+    }
+
+    public static class Builder {
+        private MetricRegistry metricsRegistry;
+        private String name;
+        private HttpClientConnectionOperator httpClientConnectionOperator;
+        private Registry<ConnectionSocketFactory> socketFactoryRegistry = getDefaultRegistry();
+        private SchemePortResolver schemePortResolver;
+        private DnsResolver dnsResolver;
+        private PoolConcurrencyPolicy poolConcurrencyPolicy;
+        private PoolReusePolicy poolReusePolicy;
+        private TimeValue timeToLive = TimeValue.NEG_ONE_MILLISECOND;
+        private HttpConnectionFactory<ManagedHttpClientConnection> connFactory;
+
+        Builder() {
+        }
+
+        public Builder metricsRegistry(MetricRegistry metricRegistry) {
+            this.metricsRegistry = requireNonNull(metricRegistry, "metricRegistry");
+            return this;
+        }
+
+        public Builder name(final String name) {
+            this.name = name;
+            return this;
+        }
+
+        public Builder socketFactoryRegistry(Registry<ConnectionSocketFactory> socketFactoryRegistry) {
+            this.socketFactoryRegistry = requireNonNull(socketFactoryRegistry, "socketFactoryRegistry");
+            return this;
+        }
+
+        public Builder connFactory(HttpConnectionFactory<ManagedHttpClientConnection> connFactory) {
+            this.connFactory = connFactory;
+            return this;
+        }
+
+        public Builder schemePortResolver(SchemePortResolver schemePortResolver) {
+            this.schemePortResolver = schemePortResolver;
+            return this;
+        }
+
+        public Builder dnsResolver(DnsResolver dnsResolver) {
+            this.dnsResolver = dnsResolver;
+            return this;
+        }
+
+        public Builder timeToLive(TimeValue timeToLive) {
+            this.timeToLive = timeToLive;
+            return this;
+        }
+
+        public Builder httpClientConnectionOperator(HttpClientConnectionOperator httpClientConnectionOperator) {
+            this.httpClientConnectionOperator = httpClientConnectionOperator;
+            return this;
+        }
+
+        public Builder poolConcurrencyPolicy(PoolConcurrencyPolicy poolConcurrencyPolicy) {
+            this.poolConcurrencyPolicy = poolConcurrencyPolicy;
+            return this;
+        }
+
+        public Builder poolReusePolicy(PoolReusePolicy poolReusePolicy) {
+            this.poolReusePolicy = poolReusePolicy;
+            return this;
+        }
+
+        public InstrumentedHttpClientConnectionManager build() {
+            if (httpClientConnectionOperator == null) {
+                httpClientConnectionOperator = new DefaultHttpClientConnectionOperator(
+                        socketFactoryRegistry,
+                        schemePortResolver,
+                        dnsResolver);
+            }
+
+            return new InstrumentedHttpClientConnectionManager(
+                    metricsRegistry,
+                    name,
+                    httpClientConnectionOperator,
+                    poolConcurrencyPolicy,
+                    poolReusePolicy,
+                    timeToLive,
+                    connFactory);
+        }
+    }
+}
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpClients.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpClients.java
new file mode 100644
index 0000000..f8f90f2
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpClients.java
@@ -0,0 +1,34 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+
+import static com.codahale.metrics.httpclient5.HttpClientMetricNameStrategies.METHOD_ONLY;
+
+public class InstrumentedHttpClients {
+    private InstrumentedHttpClients() {
+        super();
+    }
+
+    public static CloseableHttpClient createDefault(MetricRegistry metricRegistry) {
+        return createDefault(metricRegistry, METHOD_ONLY);
+    }
+
+    public static CloseableHttpClient createDefault(MetricRegistry metricRegistry,
+                                                    HttpClientMetricNameStrategy metricNameStrategy) {
+        return custom(metricRegistry, metricNameStrategy).build();
+    }
+
+    public static HttpClientBuilder custom(MetricRegistry metricRegistry) {
+        return custom(metricRegistry, METHOD_ONLY);
+    }
+
+    public static HttpClientBuilder custom(MetricRegistry metricRegistry,
+                                           HttpClientMetricNameStrategy metricNameStrategy) {
+        return HttpClientBuilder.create()
+                .setRequestExecutor(new InstrumentedHttpRequestExecutor(metricRegistry, metricNameStrategy))
+                .setConnectionManager(InstrumentedHttpClientConnectionManager.builder(metricRegistry).build());
+    }
+
+}
diff --git a/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpRequestExecutor.java b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpRequestExecutor.java
new file mode 100644
index 0000000..5ffc465
--- /dev/null
+++ b/metrics-httpclient5/src/main/java/com/codahale/metrics/httpclient5/InstrumentedHttpRequestExecutor.java
@@ -0,0 +1,78 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ConnectionReuseStrategy;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.impl.Http1StreamListener;
+import org.apache.hc.core5.http.impl.io.HttpRequestExecutor;
+import org.apache.hc.core5.http.io.HttpClientConnection;
+import org.apache.hc.core5.http.io.HttpResponseInformationCallback;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.util.Timeout;
+
+import java.io.IOException;
+
+public class InstrumentedHttpRequestExecutor extends HttpRequestExecutor {
+    private final MetricRegistry registry;
+    private final HttpClientMetricNameStrategy metricNameStrategy;
+    private final String name;
+
+    public InstrumentedHttpRequestExecutor(MetricRegistry registry,
+                                           HttpClientMetricNameStrategy metricNameStrategy) {
+        this(registry, metricNameStrategy, null);
+    }
+
+    public InstrumentedHttpRequestExecutor(MetricRegistry registry,
+                                           HttpClientMetricNameStrategy metricNameStrategy,
+                                           String name) {
+        this(registry, metricNameStrategy, name, HttpRequestExecutor.DEFAULT_WAIT_FOR_CONTINUE);
+    }
+
+    public InstrumentedHttpRequestExecutor(MetricRegistry registry,
+                                           HttpClientMetricNameStrategy metricNameStrategy,
+                                           String name,
+                                           Timeout waitForContinue) {
+        this(registry, metricNameStrategy, name, waitForContinue, null, null);
+    }
+
+    public InstrumentedHttpRequestExecutor(MetricRegistry registry,
+                                           HttpClientMetricNameStrategy metricNameStrategy,
+                                           String name,
+                                           Timeout waitForContinue,
+                                           ConnectionReuseStrategy connReuseStrategy,
+                                           Http1StreamListener streamListener) {
+        super(waitForContinue, connReuseStrategy, streamListener);
+        this.registry = registry;
+        this.name = name;
+        this.metricNameStrategy = metricNameStrategy;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public ClassicHttpResponse execute(ClassicHttpRequest request, HttpClientConnection conn, HttpResponseInformationCallback informationCallback, HttpContext context) throws IOException, HttpException {
+        final Timer.Context timerContext = timer(request).time();
+        try {
+            return super.execute(request, conn, informationCallback, context);
+        } catch (HttpException | IOException e) {
+            meter(e).mark();
+            throw e;
+        } finally {
+            timerContext.stop();
+        }
+    }
+
+    private Timer timer(HttpRequest request) {
+        return registry.timer(metricNameStrategy.getNameFor(name, request));
+    }
+
+    private Meter meter(Exception e) {
+        return registry.meter(metricNameStrategy.getNameFor(name, e));
+    }
+}
diff --git a/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategiesTest.java b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategiesTest.java
new file mode 100644
index 0000000..25fa771
--- /dev/null
+++ b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/HttpClientMetricNameStrategiesTest.java
@@ -0,0 +1,82 @@
+package com.codahale.metrics.httpclient5;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.classic.methods.HttpPut;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.message.HttpRequestWrapper;
+import org.apache.hc.core5.net.URIBuilder;
+import org.junit.Test;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import static com.codahale.metrics.httpclient5.HttpClientMetricNameStrategies.HOST_AND_METHOD;
+import static com.codahale.metrics.httpclient5.HttpClientMetricNameStrategies.METHOD_ONLY;
+import static com.codahale.metrics.httpclient5.HttpClientMetricNameStrategies.QUERYLESS_URL_AND_METHOD;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HttpClientMetricNameStrategiesTest {
+
+    @Test
+    public void methodOnlyWithName() {
+        assertThat(METHOD_ONLY.getNameFor("some-service", new HttpGet("/whatever")))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.some-service.get-requests");
+    }
+
+    @Test
+    public void methodOnlyWithoutName() {
+        assertThat(METHOD_ONLY.getNameFor(null, new HttpGet("/whatever")))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.get-requests");
+    }
+
+    @Test
+    public void hostAndMethodWithName() {
+        assertThat(HOST_AND_METHOD.getNameFor("some-service", new HttpPost("http://my.host.com/whatever")))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.some-service.my.host.com.post-requests");
+    }
+
+    @Test
+    public void hostAndMethodWithoutName() {
+        assertThat(HOST_AND_METHOD.getNameFor(null, new HttpPost("http://my.host.com/whatever")))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.my.host.com.post-requests");
+    }
+
+    @Test
+    public void hostAndMethodWithNameInWrappedRequest() throws URISyntaxException {
+        HttpRequest request = rewriteRequestURI(new HttpPost("http://my.host.com/whatever"));
+
+        assertThat(HOST_AND_METHOD.getNameFor("some-service", request))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.some-service.my.host.com.post-requests");
+    }
+
+    @Test
+    public void hostAndMethodWithoutNameInWrappedRequest() throws URISyntaxException {
+        HttpRequest request = rewriteRequestURI(new HttpPost("http://my.host.com/whatever"));
+
+        assertThat(HOST_AND_METHOD.getNameFor(null, request))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.my.host.com.post-requests");
+    }
+
+    @Test
+    public void querylessUrlAndMethodWithName() {
+        assertThat(QUERYLESS_URL_AND_METHOD.getNameFor(
+                "some-service", new HttpPut("https://thing.com:8090/my/path?ignore=this&and=this")))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.some-service.https://thing.com:8090/my/path.put-requests");
+    }
+
+    @Test
+    public void querylessUrlAndMethodWithNameInWrappedRequest() throws URISyntaxException {
+        HttpRequest request = rewriteRequestURI(new HttpPut("https://thing.com:8090/my/path?ignore=this&and=this"));
+        assertThat(QUERYLESS_URL_AND_METHOD.getNameFor("some-service", request))
+                .isEqualTo("org.apache.hc.client5.http.classic.HttpClient.some-service.https://thing.com:8090/my/path.put-requests");
+    }
+
+    private static HttpRequest rewriteRequestURI(HttpRequest request) throws URISyntaxException {
+        URI uri = new URIBuilder(request.getUri()).setFragment(null).build();
+        HttpRequestWrapper wrapper = new HttpRequestWrapper(request);
+        wrapper.setUri(uri);
+
+        return wrapper;
+    }
+}
diff --git a/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedAsyncClientConnectionManagerTest.java b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedAsyncClientConnectionManagerTest.java
new file mode 100644
index 0000000..7931692
--- /dev/null
+++ b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedAsyncClientConnectionManagerTest.java
@@ -0,0 +1,48 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import static junit.framework.TestCase.assertTrue;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.ArgumentMatchers.any;
+
+public class InstrumentedAsyncClientConnectionManagerTest {
+    private final MetricRegistry metricRegistry = new MetricRegistry();
+
+    @Test
+    public void shouldRemoveGauges() {
+        final InstrumentedAsyncClientConnectionManager instrumentedHttpClientConnectionManager = InstrumentedAsyncClientConnectionManager.builder(metricRegistry).build();
+        assertThat(metricRegistry.getGauges().entrySet().stream()
+                .map(e -> entry(e.getKey(), e.getValue().getValue())))
+                .containsOnly(entry("org.apache.hc.client5.http.nio.AsyncClientConnectionManager.available-connections", 0),
+                        entry("org.apache.hc.client5.http.nio.AsyncClientConnectionManager.leased-connections", 0),
+                        entry("org.apache.hc.client5.http.nio.AsyncClientConnectionManager.max-connections", 25),
+                        entry("org.apache.hc.client5.http.nio.AsyncClientConnectionManager.pending-connections", 0));
+
+        instrumentedHttpClientConnectionManager.close();
+        Assert.assertEquals(0, metricRegistry.getGauges().size());
+
+        // should be able to create another one with the same name ("")
+        InstrumentedHttpClientConnectionManager.builder(metricRegistry).build().close();
+    }
+
+    @Test
+    public void configurableViaBuilder() {
+        final MetricRegistry registry = Mockito.mock(MetricRegistry.class);
+
+        InstrumentedAsyncClientConnectionManager.builder(registry)
+                .name("some-name")
+                .name("some-other-name")
+                .build()
+                .close();
+
+        ArgumentCaptor<String> argumentCaptor = ArgumentCaptor.forClass(String.class);
+        Mockito.verify(registry, Mockito.atLeast(1)).registerGauge(argumentCaptor.capture(), any());
+        assertTrue(argumentCaptor.getValue().contains("some-other-name"));
+    }
+}
diff --git a/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpAsyncClientsTest.java b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpAsyncClientsTest.java
new file mode 100644
index 0000000..ebef1f2
--- /dev/null
+++ b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpAsyncClientsTest.java
@@ -0,0 +1,203 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.MetricRegistryListener;
+import com.codahale.metrics.Timer;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.ConnectionClosedException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class InstrumentedHttpAsyncClientsTest {
+    @Rule
+    public final MockitoRule mockitoRule = MockitoJUnit.rule();
+
+    @Mock
+    private HttpClientMetricNameStrategy metricNameStrategy;
+    @Mock
+    private MetricRegistryListener registryListener;
+    private HttpServer httpServer;
+    private MetricRegistry metricRegistry;
+    private CloseableHttpAsyncClient client;
+
+    @Before
+    public void setUp() throws IOException {
+        httpServer = HttpServer.create(new InetSocketAddress(0), 0);
+
+        metricRegistry = new MetricRegistry();
+        metricRegistry.addListener(registryListener);
+    }
+
+    @After
+    public void tearDown() throws IOException {
+        if (client != null) {
+            client.close();
+        }
+        if (httpServer != null) {
+            httpServer.stop(0);
+        }
+    }
+
+    @Test
+    public void registersExpectedMetricsGivenNameStrategy() throws Exception {
+        client = InstrumentedHttpAsyncClients.custom(metricRegistry, metricNameStrategy).disableAutomaticRetries().build();
+        client.start();
+
+        final SimpleHttpRequest request = SimpleRequestBuilder
+                .get("http://localhost:" + httpServer.getAddress().getPort() + "/")
+                .build();
+        final String metricName = "some.made.up.metric.name";
+
+        httpServer.createContext("/", exchange -> {
+            exchange.sendResponseHeaders(200, 0L);
+            exchange.setStreams(null, null);
+            exchange.getResponseBody().write("TEST".getBytes(StandardCharsets.US_ASCII));
+            exchange.close();
+        });
+        httpServer.start();
+
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class))).thenReturn(metricName);
+
+        final Future<SimpleHttpResponse> responseFuture = client.execute(request, new FutureCallback<SimpleHttpResponse>() {
+            @Override
+            public void completed(SimpleHttpResponse result) {
+                assertThat(result.getBodyText()).isEqualTo("TEST");
+            }
+
+            @Override
+            public void failed(Exception ex) {
+                fail();
+            }
+
+            @Override
+            public void cancelled() {
+                fail();
+            }
+        });
+        responseFuture.get(1L, TimeUnit.SECONDS);
+
+        verify(registryListener).onTimerAdded(eq(metricName), any(Timer.class));
+    }
+
+    @Test
+    public void registersExpectedExceptionMetrics() throws Exception {
+        client = InstrumentedHttpAsyncClients.custom(metricRegistry, metricNameStrategy).disableAutomaticRetries().build();
+        client.start();
+
+        final CountDownLatch countDownLatch = new CountDownLatch(1);
+        final SimpleHttpRequest request = SimpleRequestBuilder
+                .get("http://localhost:" + httpServer.getAddress().getPort() + "/")
+                .build();
+        final String requestMetricName = "request";
+        final String exceptionMetricName = "exception";
+
+        httpServer.createContext("/", HttpExchange::close);
+        httpServer.start();
+
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class)))
+                .thenReturn(requestMetricName);
+        when(metricNameStrategy.getNameFor(any(), any(Exception.class)))
+                .thenReturn(exceptionMetricName);
+
+        try {
+            final Future<SimpleHttpResponse> responseFuture = client.execute(request, new FutureCallback<SimpleHttpResponse>() {
+                @Override
+                public void completed(SimpleHttpResponse result) {
+                    fail();
+                }
+
+                @Override
+                public void failed(Exception ex) {
+                    countDownLatch.countDown();
+                }
+
+                @Override
+                public void cancelled() {
+                    fail();
+                }
+            });
+            countDownLatch.await(5, TimeUnit.SECONDS);
+            responseFuture.get(5, TimeUnit.SECONDS);
+
+            fail();
+        } catch (ExecutionException e) {
+            assertThat(e).hasCauseInstanceOf(ConnectionClosedException.class);
+            await().atMost(5, TimeUnit.SECONDS)
+                    .untilAsserted(() -> assertThat(metricRegistry.getMeters()).containsKey("exception"));
+        }
+    }
+
+    @Test
+    public void usesCustomClientConnectionManager() throws Exception {
+        try(PoolingAsyncClientConnectionManager clientConnectionManager = spy(new PoolingAsyncClientConnectionManager())) {
+        client = InstrumentedHttpAsyncClients.custom(metricRegistry, metricNameStrategy, clientConnectionManager).disableAutomaticRetries().build();
+        client.start();
+
+        final SimpleHttpRequest request = SimpleRequestBuilder
+                .get("http://localhost:" + httpServer.getAddress().getPort() + "/")
+                .build();
+        final String metricName = "some.made.up.metric.name";
+
+        httpServer.createContext("/", exchange -> {
+            exchange.sendResponseHeaders(200, 0L);
+            exchange.setStreams(null, null);
+            exchange.getResponseBody().write("TEST".getBytes(StandardCharsets.US_ASCII));
+            exchange.close();
+        });
+        httpServer.start();
+
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class))).thenReturn(metricName);
+
+        final Future<SimpleHttpResponse> responseFuture = client.execute(request, new FutureCallback<SimpleHttpResponse>() {
+            @Override
+            public void completed(SimpleHttpResponse result) {
+                assertThat(result.getCode()).isEqualTo(200);
+            }
+
+            @Override
+            public void failed(Exception ex) {
+                fail();
+            }
+
+            @Override
+            public void cancelled() {
+                fail();
+            }
+        });
+        responseFuture.get(1L, TimeUnit.SECONDS);
+
+        verify(clientConnectionManager, atLeastOnce()).connect(any(), any(), any(), any(), any(), any());
+        }
+    }
+}
diff --git a/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientConnectionManagerTest.java b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientConnectionManagerTest.java
new file mode 100644
index 0000000..c2d1571
--- /dev/null
+++ b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientConnectionManagerTest.java
@@ -0,0 +1,48 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import static junit.framework.TestCase.assertTrue;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.ArgumentMatchers.any;
+
+public class InstrumentedHttpClientConnectionManagerTest {
+    private final MetricRegistry metricRegistry = new MetricRegistry();
+
+    @Test
+    public void shouldRemoveGauges() {
+        final InstrumentedHttpClientConnectionManager instrumentedHttpClientConnectionManager = InstrumentedHttpClientConnectionManager.builder(metricRegistry).build();
+        assertThat(metricRegistry.getGauges().entrySet().stream()
+                .map(e -> entry(e.getKey(), e.getValue().getValue())))
+                .containsOnly(entry("org.apache.hc.client5.http.io.HttpClientConnectionManager.available-connections", 0),
+                        entry("org.apache.hc.client5.http.io.HttpClientConnectionManager.leased-connections", 0),
+                        entry("org.apache.hc.client5.http.io.HttpClientConnectionManager.max-connections", 25),
+                        entry("org.apache.hc.client5.http.io.HttpClientConnectionManager.pending-connections", 0));
+
+        instrumentedHttpClientConnectionManager.close();
+        Assert.assertEquals(0, metricRegistry.getGauges().size());
+
+        // should be able to create another one with the same name ("")
+        InstrumentedHttpClientConnectionManager.builder(metricRegistry).build().close();
+    }
+
+    @Test
+    public void configurableViaBuilder() {
+        final MetricRegistry registry = Mockito.mock(MetricRegistry.class);
+
+        InstrumentedHttpClientConnectionManager.builder(registry)
+                .name("some-name")
+                .name("some-other-name")
+                .build()
+                .close();
+
+        ArgumentCaptor<String> argumentCaptor = ArgumentCaptor.forClass(String.class);
+        Mockito.verify(registry, Mockito.atLeast(1)).registerGauge(argumentCaptor.capture(), any());
+        assertTrue(argumentCaptor.getValue().contains("some-other-name"));
+    }
+}
diff --git a/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientsTest.java b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientsTest.java
new file mode 100644
index 0000000..8d11929
--- /dev/null
+++ b/metrics-httpclient5/src/test/java/com/codahale/metrics/httpclient5/InstrumentedHttpClientsTest.java
@@ -0,0 +1,77 @@
+package com.codahale.metrics.httpclient5;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.MetricRegistryListener;
+import com.codahale.metrics.Timer;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.NoHttpResponseException;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class InstrumentedHttpClientsTest {
+    private final HttpClientMetricNameStrategy metricNameStrategy =
+            mock(HttpClientMetricNameStrategy.class);
+    private final MetricRegistryListener registryListener =
+            mock(MetricRegistryListener.class);
+    private final MetricRegistry metricRegistry = new MetricRegistry();
+    private final HttpClient client =
+            InstrumentedHttpClients.custom(metricRegistry, metricNameStrategy).disableAutomaticRetries().build();
+
+    @Before
+    public void setUp() {
+        metricRegistry.addListener(registryListener);
+    }
+
+    @Test
+    public void registersExpectedMetricsGivenNameStrategy() throws Exception {
+        final HttpGet get = new HttpGet("http://example.com?q=anything");
+        final String metricName = "some.made.up.metric.name";
+
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class)))
+                .thenReturn(metricName);
+
+        client.execute(get);
+
+        verify(registryListener).onTimerAdded(eq(metricName), any(Timer.class));
+    }
+
+    @Test
+    public void registersExpectedExceptionMetrics() throws Exception {
+        HttpServer httpServer = HttpServer.create(new InetSocketAddress(0), 0);
+
+        final HttpGet get = new HttpGet("http://localhost:" + httpServer.getAddress().getPort() + "/");
+        final String requestMetricName = "request";
+        final String exceptionMetricName = "exception";
+
+        httpServer.createContext("/", HttpExchange::close);
+        httpServer.start();
+
+        when(metricNameStrategy.getNameFor(any(), any(HttpRequest.class)))
+                .thenReturn(requestMetricName);
+        when(metricNameStrategy.getNameFor(any(), any(Exception.class)))
+                .thenReturn(exceptionMetricName);
+
+        try {
+            client.execute(get);
+            fail();
+        } catch (NoHttpResponseException expected) {
+            assertThat(metricRegistry.getMeters()).containsKey("exception");
+        } finally {
+            httpServer.stop(0);
+        }
+    }
+}
diff --git a/metrics-jakarta-servlet/pom.xml b/metrics-jakarta-servlet/pom.xml
new file mode 100644
index 0000000..7e3eac3
--- /dev/null
+++ b/metrics-jakarta-servlet/pom.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jakarta-servlet</artifactId>
+    <name>Metrics Integration for Jakarta Servlets</name>
+    <packaging>bundle</packaging>
+    <description>
+        An instrumented filter for servlet environments.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.servlet</javaModuleName>
+        <servlet.version>5.0.0</servlet.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.servlet</groupId>
+            <artifactId>jakarta.servlet-api</artifactId>
+            <version>${servlet.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/AbstractInstrumentedFilter.java b/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/AbstractInstrumentedFilter.java
new file mode 100644
index 0000000..c895565
--- /dev/null
+++ b/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/AbstractInstrumentedFilter.java
@@ -0,0 +1,218 @@
+package io.dropwizard.metrics.servlet;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import jakarta.servlet.AsyncEvent;
+import jakarta.servlet.AsyncListener;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpServletResponseWrapper;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+/**
+ * {@link Filter} implementation which captures request information and a breakdown of the response
+ * codes being returned.
+ */
+public abstract class AbstractInstrumentedFilter implements Filter {
+    static final String METRIC_PREFIX = "name-prefix";
+
+    private final String otherMetricName;
+    private final Map<Integer, String> meterNamesByStatusCode;
+    private final String registryAttribute;
+
+    // initialized after call of init method
+    private ConcurrentMap<Integer, Meter> metersByStatusCode;
+    private Meter otherMeter;
+    private Meter timeoutsMeter;
+    private Meter errorsMeter;
+    private Counter activeRequests;
+    private Timer requestTimer;
+
+
+    /**
+     * Creates a new instance of the filter.
+     *
+     * @param registryAttribute      the attribute used to look up the metrics registry in the
+     *                               servlet context
+     * @param meterNamesByStatusCode A map, keyed by status code, of meter names that we are
+     *                               interested in.
+     * @param otherMetricName        The name used for the catch-all meter.
+     */
+    protected AbstractInstrumentedFilter(String registryAttribute,
+                                         Map<Integer, String> meterNamesByStatusCode,
+                                         String otherMetricName) {
+        this.registryAttribute = registryAttribute;
+        this.otherMetricName = otherMetricName;
+        this.meterNamesByStatusCode = meterNamesByStatusCode;
+    }
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+        final MetricRegistry metricsRegistry = getMetricsFactory(filterConfig);
+
+        String metricName = filterConfig.getInitParameter(METRIC_PREFIX);
+        if (metricName == null || metricName.isEmpty()) {
+            metricName = getClass().getName();
+        }
+
+        this.metersByStatusCode = new ConcurrentHashMap<>(meterNamesByStatusCode.size());
+        for (Entry<Integer, String> entry : meterNamesByStatusCode.entrySet()) {
+            metersByStatusCode.put(entry.getKey(),
+                    metricsRegistry.meter(name(metricName, entry.getValue())));
+        }
+        this.otherMeter = metricsRegistry.meter(name(metricName, otherMetricName));
+        this.timeoutsMeter = metricsRegistry.meter(name(metricName, "timeouts"));
+        this.errorsMeter = metricsRegistry.meter(name(metricName, "errors"));
+        this.activeRequests = metricsRegistry.counter(name(metricName, "activeRequests"));
+        this.requestTimer = metricsRegistry.timer(name(metricName, "requests"));
+
+    }
+
+    private MetricRegistry getMetricsFactory(FilterConfig filterConfig) {
+        final MetricRegistry metricsRegistry;
+
+        final Object o = filterConfig.getServletContext().getAttribute(this.registryAttribute);
+        if (o instanceof MetricRegistry) {
+            metricsRegistry = (MetricRegistry) o;
+        } else {
+            metricsRegistry = new MetricRegistry();
+        }
+        return metricsRegistry;
+    }
+
+    @Override
+    public void destroy() {
+
+    }
+
+    @Override
+    public void doFilter(ServletRequest request,
+                         ServletResponse response,
+                         FilterChain chain) throws IOException, ServletException {
+        final StatusExposingServletResponse wrappedResponse =
+                new StatusExposingServletResponse((HttpServletResponse) response);
+        activeRequests.inc();
+        final Timer.Context context = requestTimer.time();
+        boolean error = false;
+        try {
+            chain.doFilter(request, wrappedResponse);
+        } catch (IOException | RuntimeException | ServletException e) {
+            error = true;
+            throw e;
+        } finally {
+            if (!error && request.isAsyncStarted()) {
+                request.getAsyncContext().addListener(new AsyncResultListener(context));
+            } else {
+                context.stop();
+                activeRequests.dec();
+                if (error) {
+                    errorsMeter.mark();
+                } else {
+                    markMeterForStatusCode(wrappedResponse.getStatus());
+                }
+            }
+        }
+    }
+
+    private void markMeterForStatusCode(int status) {
+        final Meter metric = metersByStatusCode.get(status);
+        if (metric != null) {
+            metric.mark();
+        } else {
+            otherMeter.mark();
+        }
+    }
+
+    private static class StatusExposingServletResponse extends HttpServletResponseWrapper {
+        // The Servlet spec says: calling setStatus is optional, if no status is set, the default is 200.
+        private int httpStatus = 200;
+
+        public StatusExposingServletResponse(HttpServletResponse response) {
+            super(response);
+        }
+
+        @Override
+        public void sendError(int sc) throws IOException {
+            httpStatus = sc;
+            super.sendError(sc);
+        }
+
+        @Override
+        public void sendError(int sc, String msg) throws IOException {
+            httpStatus = sc;
+            super.sendError(sc, msg);
+        }
+
+        @Override
+        public void setStatus(int sc) {
+            httpStatus = sc;
+            super.setStatus(sc);
+        }
+
+        @Override
+        @SuppressWarnings("deprecation")
+        public void setStatus(int sc, String sm) {
+            httpStatus = sc;
+            super.setStatus(sc, sm);
+        }
+
+        @Override
+        public int getStatus() {
+            return httpStatus;
+        }
+    }
+
+    private class AsyncResultListener implements AsyncListener {
+        private Timer.Context context;
+        private boolean done = false;
+
+        public AsyncResultListener(Timer.Context context) {
+            this.context = context;
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            if (!done) {
+                HttpServletResponse suppliedResponse = (HttpServletResponse) event.getSuppliedResponse();
+                context.stop();
+                activeRequests.dec();
+                markMeterForStatusCode(suppliedResponse.getStatus());
+            }
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            context.stop();
+            activeRequests.dec();
+            timeoutsMeter.mark();
+            done = true;
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+            context.stop();
+            activeRequests.dec();
+            errorsMeter.mark();
+            done = true;
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+
+        }
+    }
+}
diff --git a/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/InstrumentedFilter.java b/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/InstrumentedFilter.java
new file mode 100644
index 0000000..17538af
--- /dev/null
+++ b/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/InstrumentedFilter.java
@@ -0,0 +1,48 @@
+package io.dropwizard.metrics.servlet;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Implementation of the {@link AbstractInstrumentedFilter} which provides a default set of response codes
+ * to capture information about. <p>Use it in your servlet.xml like this:<p>
+ * <pre>{@code
+ * <filter>
+ *     <filter-name>instrumentedFilter</filter-name>
+ *     <filter-class>io.dropwizard.metrics.servlet.InstrumentedFilter</filter-class>
+ * </filter>
+ * <filter-mapping>
+ *     <filter-name>instrumentedFilter</filter-name>
+ *     <url-pattern>/*</url-pattern>
+ * </filter-mapping>
+ * }</pre>
+ */
+public class InstrumentedFilter extends AbstractInstrumentedFilter {
+    public static final String REGISTRY_ATTRIBUTE = InstrumentedFilter.class.getName() + ".registry";
+
+    private static final String NAME_PREFIX = "responseCodes.";
+    private static final int OK = 200;
+    private static final int CREATED = 201;
+    private static final int NO_CONTENT = 204;
+    private static final int BAD_REQUEST = 400;
+    private static final int NOT_FOUND = 404;
+    private static final int SERVER_ERROR = 500;
+
+    /**
+     * Creates a new instance of the filter.
+     */
+    public InstrumentedFilter() {
+        super(REGISTRY_ATTRIBUTE, createMeterNamesByStatusCode(), NAME_PREFIX + "other");
+    }
+
+    private static Map<Integer, String> createMeterNamesByStatusCode() {
+        final Map<Integer, String> meterNamesByStatusCode = new HashMap<>(6);
+        meterNamesByStatusCode.put(OK, NAME_PREFIX + "ok");
+        meterNamesByStatusCode.put(CREATED, NAME_PREFIX + "created");
+        meterNamesByStatusCode.put(NO_CONTENT, NAME_PREFIX + "noContent");
+        meterNamesByStatusCode.put(BAD_REQUEST, NAME_PREFIX + "badRequest");
+        meterNamesByStatusCode.put(NOT_FOUND, NAME_PREFIX + "notFound");
+        meterNamesByStatusCode.put(SERVER_ERROR, NAME_PREFIX + "serverError");
+        return meterNamesByStatusCode;
+    }
+}
diff --git a/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/InstrumentedFilterContextListener.java b/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/InstrumentedFilterContextListener.java
new file mode 100644
index 0000000..04d7b65
--- /dev/null
+++ b/metrics-jakarta-servlet/src/main/java/io/dropwizard/metrics/servlet/InstrumentedFilterContextListener.java
@@ -0,0 +1,26 @@
+package io.dropwizard.metrics.servlet;
+
+import com.codahale.metrics.MetricRegistry;
+import jakarta.servlet.ServletContextEvent;
+import jakarta.servlet.ServletContextListener;
+
+/**
+ * A listener implementation which injects a {@link MetricRegistry} instance into the servlet
+ * context. Implement {@link #getMetricRegistry()} to return the {@link MetricRegistry} for your
+ * application.
+ */
+public abstract class InstrumentedFilterContextListener implements ServletContextListener {
+    /**
+     * @return the {@link MetricRegistry} to inject into the servlet context.
+     */
+    protected abstract MetricRegistry getMetricRegistry();
+
+    @Override
+    public void contextInitialized(ServletContextEvent sce) {
+        sce.getServletContext().setAttribute(InstrumentedFilter.REGISTRY_ATTRIBUTE, getMetricRegistry());
+    }
+
+    @Override
+    public void contextDestroyed(ServletContextEvent sce) {
+    }
+}
diff --git a/metrics-jakarta-servlet/src/test/java/io/dropwizard/metrics/servlet/InstrumentedFilterContextListenerTest.java b/metrics-jakarta-servlet/src/test/java/io/dropwizard/metrics/servlet/InstrumentedFilterContextListenerTest.java
new file mode 100644
index 0000000..b586a8d
--- /dev/null
+++ b/metrics-jakarta-servlet/src/test/java/io/dropwizard/metrics/servlet/InstrumentedFilterContextListenerTest.java
@@ -0,0 +1,32 @@
+package io.dropwizard.metrics.servlet;
+
+import com.codahale.metrics.MetricRegistry;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletContextEvent;
+import org.junit.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class InstrumentedFilterContextListenerTest {
+    private final MetricRegistry registry = mock(MetricRegistry.class);
+    private final InstrumentedFilterContextListener listener = new InstrumentedFilterContextListener() {
+        @Override
+        protected MetricRegistry getMetricRegistry() {
+            return registry;
+        }
+    };
+
+    @Test
+    public void injectsTheMetricRegistryIntoTheServletContext() {
+        final ServletContext context = mock(ServletContext.class);
+
+        final ServletContextEvent event = mock(ServletContextEvent.class);
+        when(event.getServletContext()).thenReturn(context);
+
+        listener.contextInitialized(event);
+
+        verify(context).setAttribute("io.dropwizard.metrics.servlet.InstrumentedFilter.registry", registry);
+    }
+}
diff --git a/metrics-jakarta-servlet6/pom.xml b/metrics-jakarta-servlet6/pom.xml
new file mode 100644
index 0000000..c5d796f
--- /dev/null
+++ b/metrics-jakarta-servlet6/pom.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jakarta-servlet6</artifactId>
+    <name>Metrics Integration for Jakarta Servlets 6.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        An instrumented filter for servlet 6.x environments.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.servlet</javaModuleName>
+        <servlet6.version>6.0.0</servlet6.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.servlet</groupId>
+            <artifactId>jakarta.servlet-api</artifactId>
+            <version>${servlet6.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/AbstractInstrumentedFilter.java b/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/AbstractInstrumentedFilter.java
new file mode 100644
index 0000000..9134247
--- /dev/null
+++ b/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/AbstractInstrumentedFilter.java
@@ -0,0 +1,211 @@
+package io.dropwizard.metrics.servlet6;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import jakarta.servlet.AsyncEvent;
+import jakarta.servlet.AsyncListener;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpServletResponseWrapper;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+/**
+ * {@link Filter} implementation which captures request information and a breakdown of the response
+ * codes being returned.
+ */
+public abstract class AbstractInstrumentedFilter implements Filter {
+    static final String METRIC_PREFIX = "name-prefix";
+
+    private final String otherMetricName;
+    private final Map<Integer, String> meterNamesByStatusCode;
+    private final String registryAttribute;
+
+    // initialized after call of init method
+    private ConcurrentMap<Integer, Meter> metersByStatusCode;
+    private Meter otherMeter;
+    private Meter timeoutsMeter;
+    private Meter errorsMeter;
+    private Counter activeRequests;
+    private Timer requestTimer;
+
+
+    /**
+     * Creates a new instance of the filter.
+     *
+     * @param registryAttribute      the attribute used to look up the metrics registry in the
+     *                               servlet context
+     * @param meterNamesByStatusCode A map, keyed by status code, of meter names that we are
+     *                               interested in.
+     * @param otherMetricName        The name used for the catch-all meter.
+     */
+    protected AbstractInstrumentedFilter(String registryAttribute,
+                                         Map<Integer, String> meterNamesByStatusCode,
+                                         String otherMetricName) {
+        this.registryAttribute = registryAttribute;
+        this.otherMetricName = otherMetricName;
+        this.meterNamesByStatusCode = meterNamesByStatusCode;
+    }
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+        final MetricRegistry metricsRegistry = getMetricsFactory(filterConfig);
+
+        String metricName = filterConfig.getInitParameter(METRIC_PREFIX);
+        if (metricName == null || metricName.isEmpty()) {
+            metricName = getClass().getName();
+        }
+
+        this.metersByStatusCode = new ConcurrentHashMap<>(meterNamesByStatusCode.size());
+        for (Entry<Integer, String> entry : meterNamesByStatusCode.entrySet()) {
+            metersByStatusCode.put(entry.getKey(),
+                    metricsRegistry.meter(name(metricName, entry.getValue())));
+        }
+        this.otherMeter = metricsRegistry.meter(name(metricName, otherMetricName));
+        this.timeoutsMeter = metricsRegistry.meter(name(metricName, "timeouts"));
+        this.errorsMeter = metricsRegistry.meter(name(metricName, "errors"));
+        this.activeRequests = metricsRegistry.counter(name(metricName, "activeRequests"));
+        this.requestTimer = metricsRegistry.timer(name(metricName, "requests"));
+
+    }
+
+    private MetricRegistry getMetricsFactory(FilterConfig filterConfig) {
+        final MetricRegistry metricsRegistry;
+
+        final Object o = filterConfig.getServletContext().getAttribute(this.registryAttribute);
+        if (o instanceof MetricRegistry) {
+            metricsRegistry = (MetricRegistry) o;
+        } else {
+            metricsRegistry = new MetricRegistry();
+        }
+        return metricsRegistry;
+    }
+
+    @Override
+    public void destroy() {
+
+    }
+
+    @Override
+    public void doFilter(ServletRequest request,
+                         ServletResponse response,
+                         FilterChain chain) throws IOException, ServletException {
+        final StatusExposingServletResponse wrappedResponse =
+                new StatusExposingServletResponse((HttpServletResponse) response);
+        activeRequests.inc();
+        final Timer.Context context = requestTimer.time();
+        boolean error = false;
+        try {
+            chain.doFilter(request, wrappedResponse);
+        } catch (IOException | RuntimeException | ServletException e) {
+            error = true;
+            throw e;
+        } finally {
+            if (!error && request.isAsyncStarted()) {
+                request.getAsyncContext().addListener(new AsyncResultListener(context));
+            } else {
+                context.stop();
+                activeRequests.dec();
+                if (error) {
+                    errorsMeter.mark();
+                } else {
+                    markMeterForStatusCode(wrappedResponse.getStatus());
+                }
+            }
+        }
+    }
+
+    private void markMeterForStatusCode(int status) {
+        final Meter metric = metersByStatusCode.get(status);
+        if (metric != null) {
+            metric.mark();
+        } else {
+            otherMeter.mark();
+        }
+    }
+
+    private static class StatusExposingServletResponse extends HttpServletResponseWrapper {
+        // The Servlet spec says: calling setStatus is optional, if no status is set, the default is 200.
+        private int httpStatus = 200;
+
+        public StatusExposingServletResponse(HttpServletResponse response) {
+            super(response);
+        }
+
+        @Override
+        public void sendError(int sc) throws IOException {
+            httpStatus = sc;
+            super.sendError(sc);
+        }
+
+        @Override
+        public void sendError(int sc, String msg) throws IOException {
+            httpStatus = sc;
+            super.sendError(sc, msg);
+        }
+
+        @Override
+        public void setStatus(int sc) {
+            httpStatus = sc;
+            super.setStatus(sc);
+        }
+
+        @Override
+        public int getStatus() {
+            return httpStatus;
+        }
+    }
+
+    private class AsyncResultListener implements AsyncListener {
+        private final Timer.Context context;
+        private boolean done = false;
+
+        public AsyncResultListener(Timer.Context context) {
+            this.context = context;
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            if (!done) {
+                HttpServletResponse suppliedResponse = (HttpServletResponse) event.getSuppliedResponse();
+                context.stop();
+                activeRequests.dec();
+                markMeterForStatusCode(suppliedResponse.getStatus());
+            }
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            context.stop();
+            activeRequests.dec();
+            timeoutsMeter.mark();
+            done = true;
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+            context.stop();
+            activeRequests.dec();
+            errorsMeter.mark();
+            done = true;
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+
+        }
+    }
+}
diff --git a/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/InstrumentedFilter.java b/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/InstrumentedFilter.java
new file mode 100644
index 0000000..e4b37fd
--- /dev/null
+++ b/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/InstrumentedFilter.java
@@ -0,0 +1,48 @@
+package io.dropwizard.metrics.servlet6;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Implementation of the {@link AbstractInstrumentedFilter} which provides a default set of response codes
+ * to capture information about. <p>Use it in your servlet.xml like this:<p>
+ * <pre>{@code
+ * <filter>
+ *     <filter-name>instrumentedFilter</filter-name>
+ *     <filter-class>io.dropwizard.metrics.servlet.InstrumentedFilter</filter-class>
+ * </filter>
+ * <filter-mapping>
+ *     <filter-name>instrumentedFilter</filter-name>
+ *     <url-pattern>/*</url-pattern>
+ * </filter-mapping>
+ * }</pre>
+ */
+public class InstrumentedFilter extends AbstractInstrumentedFilter {
+    public static final String REGISTRY_ATTRIBUTE = InstrumentedFilter.class.getName() + ".registry";
+
+    private static final String NAME_PREFIX = "responseCodes.";
+    private static final int OK = 200;
+    private static final int CREATED = 201;
+    private static final int NO_CONTENT = 204;
+    private static final int BAD_REQUEST = 400;
+    private static final int NOT_FOUND = 404;
+    private static final int SERVER_ERROR = 500;
+
+    /**
+     * Creates a new instance of the filter.
+     */
+    public InstrumentedFilter() {
+        super(REGISTRY_ATTRIBUTE, createMeterNamesByStatusCode(), NAME_PREFIX + "other");
+    }
+
+    private static Map<Integer, String> createMeterNamesByStatusCode() {
+        final Map<Integer, String> meterNamesByStatusCode = new HashMap<>(6);
+        meterNamesByStatusCode.put(OK, NAME_PREFIX + "ok");
+        meterNamesByStatusCode.put(CREATED, NAME_PREFIX + "created");
+        meterNamesByStatusCode.put(NO_CONTENT, NAME_PREFIX + "noContent");
+        meterNamesByStatusCode.put(BAD_REQUEST, NAME_PREFIX + "badRequest");
+        meterNamesByStatusCode.put(NOT_FOUND, NAME_PREFIX + "notFound");
+        meterNamesByStatusCode.put(SERVER_ERROR, NAME_PREFIX + "serverError");
+        return meterNamesByStatusCode;
+    }
+}
diff --git a/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/InstrumentedFilterContextListener.java b/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/InstrumentedFilterContextListener.java
new file mode 100644
index 0000000..b931584
--- /dev/null
+++ b/metrics-jakarta-servlet6/src/main/java/io/dropwizard/metrics/servlet6/InstrumentedFilterContextListener.java
@@ -0,0 +1,26 @@
+package io.dropwizard.metrics.servlet6;
+
+import com.codahale.metrics.MetricRegistry;
+import jakarta.servlet.ServletContextEvent;
+import jakarta.servlet.ServletContextListener;
+
+/**
+ * A listener implementation which injects a {@link MetricRegistry} instance into the servlet
+ * context. Implement {@link #getMetricRegistry()} to return the {@link MetricRegistry} for your
+ * application.
+ */
+public abstract class InstrumentedFilterContextListener implements ServletContextListener {
+    /**
+     * @return the {@link MetricRegistry} to inject into the servlet context.
+     */
+    protected abstract MetricRegistry getMetricRegistry();
+
+    @Override
+    public void contextInitialized(ServletContextEvent sce) {
+        sce.getServletContext().setAttribute(InstrumentedFilter.REGISTRY_ATTRIBUTE, getMetricRegistry());
+    }
+
+    @Override
+    public void contextDestroyed(ServletContextEvent sce) {
+    }
+}
diff --git a/metrics-jakarta-servlet6/src/test/java/io/dropwizard/metrics/servlet6/InstrumentedFilterContextListenerTest.java b/metrics-jakarta-servlet6/src/test/java/io/dropwizard/metrics/servlet6/InstrumentedFilterContextListenerTest.java
new file mode 100644
index 0000000..74062ef
--- /dev/null
+++ b/metrics-jakarta-servlet6/src/test/java/io/dropwizard/metrics/servlet6/InstrumentedFilterContextListenerTest.java
@@ -0,0 +1,32 @@
+package io.dropwizard.metrics.servlet6;
+
+import com.codahale.metrics.MetricRegistry;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletContextEvent;
+import org.junit.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class InstrumentedFilterContextListenerTest {
+    private final MetricRegistry registry = mock(MetricRegistry.class);
+    private final InstrumentedFilterContextListener listener = new InstrumentedFilterContextListener() {
+        @Override
+        protected MetricRegistry getMetricRegistry() {
+            return registry;
+        }
+    };
+
+    @Test
+    public void injectsTheMetricRegistryIntoTheServletContext() {
+        final ServletContext context = mock(ServletContext.class);
+
+        final ServletContextEvent event = mock(ServletContextEvent.class);
+        when(event.getServletContext()).thenReturn(context);
+
+        listener.contextInitialized(event);
+
+        verify(context).setAttribute("io.dropwizard.metrics.servlet6.InstrumentedFilter.registry", registry);
+    }
+}
diff --git a/metrics-jakarta-servlets/pom.xml b/metrics-jakarta-servlets/pom.xml
new file mode 100644
index 0000000..69928ab
--- /dev/null
+++ b/metrics-jakarta-servlets/pom.xml
@@ -0,0 +1,169 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jakarta-servlets</artifactId>
+    <name>Metrics Utility Jakarta Servlets</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of utility servlets for Metrics, allowing you to expose valuable information about
+        your production environment.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.servlets</javaModuleName>
+        <papertrail.profiler.version>1.1.1</papertrail.profiler.version>
+        <servlet.version>6.0.0</servlet.version>
+        <jackson.version>2.12.7.1</jackson.version>
+        <slf4j.version>2.0.11</slf4j.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-bom</artifactId>
+                <version>${jetty11.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.slf4j</groupId>
+                <artifactId>slf4j-api</artifactId>
+                <version>${slf4j.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-healthchecks</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-json</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-jvm</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>com.helger</groupId>
+            <artifactId>profiler</artifactId>
+            <version>${papertrail.profiler.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.servlet</groupId>
+            <artifactId>jakarta.servlet-api</artifactId>
+            <version>${servlet.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+            <version>${jackson.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-servlet</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-util</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-server</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-jetty11</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/AdminServlet.java b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/AdminServlet.java
new file mode 100755
index 0000000..447d1c1
--- /dev/null
+++ b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/AdminServlet.java
@@ -0,0 +1,190 @@
+package io.dropwizard.metrics.servlets;
+
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.text.MessageFormat;
+
+public class AdminServlet extends HttpServlet {
+    public static final String DEFAULT_HEALTHCHECK_URI = "/healthcheck";
+    public static final String DEFAULT_METRICS_URI = "/metrics";
+    public static final String DEFAULT_PING_URI = "/ping";
+    public static final String DEFAULT_THREADS_URI = "/threads";
+    public static final String DEFAULT_CPU_PROFILE_URI = "/pprof";
+
+    public static final String METRICS_ENABLED_PARAM_KEY = "metrics-enabled";
+    public static final String METRICS_URI_PARAM_KEY = "metrics-uri";
+    public static final String PING_ENABLED_PARAM_KEY = "ping-enabled";
+    public static final String PING_URI_PARAM_KEY = "ping-uri";
+    public static final String THREADS_ENABLED_PARAM_KEY = "threads-enabled";
+    public static final String THREADS_URI_PARAM_KEY = "threads-uri";
+    public static final String HEALTHCHECK_ENABLED_PARAM_KEY = "healthcheck-enabled";
+    public static final String HEALTHCHECK_URI_PARAM_KEY = "healthcheck-uri";
+    public static final String SERVICE_NAME_PARAM_KEY = "service-name";
+    public static final String CPU_PROFILE_ENABLED_PARAM_KEY = "cpu-profile-enabled";
+    public static final String CPU_PROFILE_URI_PARAM_KEY = "cpu-profile-uri";
+
+    private static final String BASE_TEMPLATE =
+            "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"%n" +
+                    "        \"http://www.w3.org/TR/html4/loose.dtd\">%n" +
+                    "<html>%n" +
+                    "<head>%n" +
+                    "  <title>Metrics{10}</title>%n" +
+                    "</head>%n" +
+                    "<body>%n" +
+                    "  <h1>Operational Menu{10}</h1>%n" +
+                    "  <ul>%n" +
+                    "%s" +
+                    "  </ul>%n" +
+                    "</body>%n" +
+                    "</html>";
+    private static final String METRICS_LINK = "    <li><a href=\"{0}{1}?pretty=true\">Metrics</a></li>%n";
+    private static final String PING_LINK = "    <li><a href=\"{2}{3}\">Ping</a></li>%n" ;
+    private static final String THREADS_LINK = "    <li><a href=\"{4}{5}\">Threads</a></li>%n" ;
+    private static final String HEALTHCHECK_LINK = "    <li><a href=\"{6}{7}?pretty=true\">Healthcheck</a></li>%n" ;
+    private static final String CPU_PROFILE_LINK = "    <li><a href=\"{8}{9}\">CPU Profile</a></li>%n" +
+            "    <li><a href=\"{8}{9}?state=blocked\">CPU Contention</a></li>%n";
+
+
+    private static final String CONTENT_TYPE = "text/html";
+    private static final long serialVersionUID = -2850794040708785318L;
+
+    private transient HealthCheckServlet healthCheckServlet;
+    private transient MetricsServlet metricsServlet;
+    private transient PingServlet pingServlet;
+    private transient ThreadDumpServlet threadDumpServlet;
+    private transient CpuProfileServlet cpuProfileServlet;
+    private transient boolean metricsEnabled;
+    private transient String metricsUri;
+    private transient boolean pingEnabled;
+    private transient String pingUri;
+    private transient boolean threadsEnabled;
+    private transient String threadsUri;
+    private transient boolean healthcheckEnabled;
+    private transient String healthcheckUri;
+    private transient boolean cpuProfileEnabled;
+    private transient String cpuProfileUri;
+    private transient String serviceName;
+    private transient String pageContentTemplate;
+
+    @Override
+    public void init(ServletConfig config) throws ServletException {
+        super.init(config);
+
+        final ServletContext context = config.getServletContext();
+        final StringBuilder servletLinks = new StringBuilder();
+
+        this.metricsEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(METRICS_ENABLED_PARAM_KEY), "true"));
+        if (this.metricsEnabled) {
+            servletLinks.append(METRICS_LINK);
+        }
+        this.metricsServlet = new MetricsServlet();
+        metricsServlet.init(config);
+
+        this.pingEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(PING_ENABLED_PARAM_KEY), "true"));
+        if (this.pingEnabled) {
+            servletLinks.append(PING_LINK);
+        }
+        this.pingServlet = new PingServlet();
+        pingServlet.init(config);
+
+        this.threadsEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(THREADS_ENABLED_PARAM_KEY), "true"));
+        if (this.threadsEnabled) {
+            servletLinks.append(THREADS_LINK);
+        }
+        this.threadDumpServlet = new ThreadDumpServlet();
+        threadDumpServlet.init(config);
+
+        this.healthcheckEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(HEALTHCHECK_ENABLED_PARAM_KEY), "true"));
+        if (this.healthcheckEnabled) {
+            servletLinks.append(HEALTHCHECK_LINK);
+        }
+        this.healthCheckServlet = new HealthCheckServlet();
+        healthCheckServlet.init(config);
+
+        this.cpuProfileEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(CPU_PROFILE_ENABLED_PARAM_KEY), "true"));
+        if (this.cpuProfileEnabled) {
+            servletLinks.append(CPU_PROFILE_LINK);
+        }
+        this.cpuProfileServlet = new CpuProfileServlet();
+        cpuProfileServlet.init(config);
+
+        pageContentTemplate = String.format(BASE_TEMPLATE, String.format(servletLinks.toString()));
+
+        this.metricsUri = getParam(context.getInitParameter(METRICS_URI_PARAM_KEY), DEFAULT_METRICS_URI);
+        this.pingUri = getParam(context.getInitParameter(PING_URI_PARAM_KEY), DEFAULT_PING_URI);
+        this.threadsUri = getParam(context.getInitParameter(THREADS_URI_PARAM_KEY), DEFAULT_THREADS_URI);
+        this.healthcheckUri = getParam(context.getInitParameter(HEALTHCHECK_URI_PARAM_KEY), DEFAULT_HEALTHCHECK_URI);
+        this.cpuProfileUri = getParam(context.getInitParameter(CPU_PROFILE_URI_PARAM_KEY), DEFAULT_CPU_PROFILE_URI);
+        this.serviceName = getParam(context.getInitParameter(SERVICE_NAME_PARAM_KEY), null);
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        final String path = req.getContextPath() + req.getServletPath();
+
+        resp.setStatus(HttpServletResponse.SC_OK);
+        resp.setHeader("Cache-Control", "must-revalidate,no-cache,no-store");
+        resp.setContentType(CONTENT_TYPE);
+        try (PrintWriter writer = resp.getWriter()) {
+            writer.println(MessageFormat.format(pageContentTemplate, path, metricsUri, path, pingUri, path,
+                    threadsUri, path, healthcheckUri, path, cpuProfileUri,
+                    serviceName == null ? "" : " (" + serviceName + ")"));
+        }
+    }
+
+    @Override
+    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        final String uri = req.getPathInfo();
+        if (uri == null || uri.equals("/")) {
+            super.service(req, resp);
+        } else if (uri.equals(healthcheckUri)) {
+            if (healthcheckEnabled) {
+                healthCheckServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
+        } else if (uri.startsWith(metricsUri)) {
+            if (metricsEnabled) {
+                metricsServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
+        } else if (uri.equals(pingUri)) {
+            if (pingEnabled) {
+                pingServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
+        } else if (uri.equals(threadsUri)) {
+            if (threadsEnabled) {
+                threadDumpServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
+        } else if (uri.equals(cpuProfileUri)) {
+            if (cpuProfileEnabled) {
+                cpuProfileServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
+        } else {
+            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+        }
+    }
+
+    private static String getParam(String initParam, String defaultValue) {
+        return initParam == null ? defaultValue : initParam;
+    }
+}
diff --git a/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/CpuProfileServlet.java b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/CpuProfileServlet.java
new file mode 100644
index 0000000..3e05af6
--- /dev/null
+++ b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/CpuProfileServlet.java
@@ -0,0 +1,79 @@
+package io.dropwizard.metrics.servlets;
+
+import com.papertrail.profiler.CpuProfile;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.time.Duration;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * An HTTP servlets which outputs a <a href="https://github.com/gperftools/gperftools">pprof</a> parseable response.
+ */
+public class CpuProfileServlet extends HttpServlet {
+    private static final long serialVersionUID = -668666696530287501L;
+    private static final String CONTENT_TYPE = "pprof/raw";
+    private static final String CACHE_CONTROL = "Cache-Control";
+    private static final String NO_CACHE = "must-revalidate,no-cache,no-store";
+    private final Lock lock = new ReentrantLock();
+
+    @Override
+    protected void doGet(HttpServletRequest req,
+                         HttpServletResponse resp) throws ServletException, IOException {
+
+        int duration = 10;
+        if (req.getParameter("duration") != null) {
+            try {
+                duration = Integer.parseInt(req.getParameter("duration"));
+            } catch (NumberFormatException e) {
+                duration = 10;
+            }
+        }
+
+        int frequency = 100;
+        if (req.getParameter("frequency") != null) {
+            try {
+                frequency = Integer.parseInt(req.getParameter("frequency"));
+                frequency = Math.min(Math.max(frequency, 1), 1000);
+            } catch (NumberFormatException e) {
+                frequency = 100;
+            }
+        }
+
+        final Thread.State state;
+        if ("blocked".equalsIgnoreCase(req.getParameter("state"))) {
+            state = Thread.State.BLOCKED;
+        } else {
+            state = Thread.State.RUNNABLE;
+        }
+
+        resp.setStatus(HttpServletResponse.SC_OK);
+        resp.setHeader(CACHE_CONTROL, NO_CACHE);
+        resp.setContentType(CONTENT_TYPE);
+        try (OutputStream output = resp.getOutputStream()) {
+            doProfile(output, duration, frequency, state);
+        }
+    }
+
+    protected void doProfile(OutputStream out, int duration, int frequency, Thread.State state) throws IOException {
+        if (lock.tryLock()) {
+            try {
+                CpuProfile profile = CpuProfile.record(Duration.ofSeconds(duration),
+                        frequency, state);
+                if (profile == null) {
+                    throw new RuntimeException("could not create CpuProfile");
+                }
+                profile.writeGoogleProfile(out);
+                return;
+            } finally {
+                lock.unlock();
+            }
+        }
+        throw new RuntimeException("Only one profile request may be active at a time");
+    }
+}
diff --git a/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/HealthCheckServlet.java b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/HealthCheckServlet.java
new file mode 100644
index 0000000..2af0d91
--- /dev/null
+++ b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/HealthCheckServlet.java
@@ -0,0 +1,195 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.health.HealthCheck;
+import com.codahale.metrics.health.HealthCheckFilter;
+import com.codahale.metrics.health.HealthCheckRegistry;
+import com.codahale.metrics.json.HealthCheckModule;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletContextEvent;
+import jakarta.servlet.ServletContextListener;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.concurrent.ExecutorService;
+
+public class HealthCheckServlet extends HttpServlet {
+    public static abstract class ContextListener implements ServletContextListener {
+        /**
+         * @return the {@link HealthCheckRegistry} to inject into the servlet context.
+         */
+        protected abstract HealthCheckRegistry getHealthCheckRegistry();
+
+        /**
+         * @return the {@link ExecutorService} to inject into the servlet context, or {@code null}
+         * if the health checks should be run in the servlet worker thread.
+         */
+        protected ExecutorService getExecutorService() {
+            // don't use a thread pool by default
+            return null;
+        }
+
+        /**
+         * @return the {@link HealthCheckFilter} that shall be used to filter health checks,
+         * or {@link HealthCheckFilter#ALL} if the default should be used.
+         */
+        protected HealthCheckFilter getHealthCheckFilter() {
+            return HealthCheckFilter.ALL;
+        }
+
+        /**
+         * @return the {@link ObjectMapper} that shall be used to render health checks,
+         * or {@code null} if the default object mapper should be used.
+         */
+        protected ObjectMapper getObjectMapper() {
+            // don't use an object mapper by default
+            return null;
+        }
+
+        @Override
+        public void contextInitialized(ServletContextEvent event) {
+            final ServletContext context = event.getServletContext();
+            context.setAttribute(HEALTH_CHECK_REGISTRY, getHealthCheckRegistry());
+            context.setAttribute(HEALTH_CHECK_EXECUTOR, getExecutorService());
+            context.setAttribute(HEALTH_CHECK_MAPPER, getObjectMapper());
+        }
+
+        @Override
+        public void contextDestroyed(ServletContextEvent event) {
+            // no-op
+        }
+    }
+
+    public static final String HEALTH_CHECK_REGISTRY = HealthCheckServlet.class.getCanonicalName() + ".registry";
+    public static final String HEALTH_CHECK_EXECUTOR = HealthCheckServlet.class.getCanonicalName() + ".executor";
+    public static final String HEALTH_CHECK_FILTER = HealthCheckServlet.class.getCanonicalName() + ".healthCheckFilter";
+    public static final String HEALTH_CHECK_MAPPER = HealthCheckServlet.class.getCanonicalName() + ".mapper";
+    public static final String HEALTH_CHECK_HTTP_STATUS_INDICATOR = HealthCheckServlet.class.getCanonicalName() + ".httpStatusIndicator";
+
+    private static final long serialVersionUID = -8432996484889177321L;
+    private static final String CONTENT_TYPE = "application/json";
+    private static final String HTTP_STATUS_INDICATOR_PARAM = "httpStatusIndicator";
+
+    private transient HealthCheckRegistry registry;
+    private transient ExecutorService executorService;
+    private transient HealthCheckFilter filter;
+    private transient ObjectMapper mapper;
+    private transient boolean httpStatusIndicator;
+
+    public HealthCheckServlet() {
+    }
+
+    public HealthCheckServlet(HealthCheckRegistry registry) {
+        this.registry = registry;
+    }
+
+    @Override
+    public void init(ServletConfig config) throws ServletException {
+        super.init(config);
+
+        final ServletContext context = config.getServletContext();
+        if (null == registry) {
+            final Object registryAttr = context.getAttribute(HEALTH_CHECK_REGISTRY);
+            if (registryAttr instanceof HealthCheckRegistry) {
+                this.registry = (HealthCheckRegistry) registryAttr;
+            } else {
+                throw new ServletException("Couldn't find a HealthCheckRegistry instance.");
+            }
+        }
+
+        final Object executorAttr = context.getAttribute(HEALTH_CHECK_EXECUTOR);
+        if (executorAttr instanceof ExecutorService) {
+            this.executorService = (ExecutorService) executorAttr;
+        }
+
+        final Object filterAttr = context.getAttribute(HEALTH_CHECK_FILTER);
+        if (filterAttr instanceof HealthCheckFilter) {
+            filter = (HealthCheckFilter) filterAttr;
+        }
+        if (filter == null) {
+            filter = HealthCheckFilter.ALL;
+        }
+
+        final Object mapperAttr = context.getAttribute(HEALTH_CHECK_MAPPER);
+        if (mapperAttr instanceof ObjectMapper) {
+            this.mapper = (ObjectMapper) mapperAttr;
+        } else {
+            this.mapper = new ObjectMapper();
+        }
+        this.mapper.registerModule(new HealthCheckModule());
+
+        final Object httpStatusIndicatorAttr = context.getAttribute(HEALTH_CHECK_HTTP_STATUS_INDICATOR);
+        if (httpStatusIndicatorAttr instanceof Boolean) {
+            this.httpStatusIndicator = (Boolean) httpStatusIndicatorAttr;
+        } else {
+            this.httpStatusIndicator = true;
+        }
+    }
+
+    @Override
+    public void destroy() {
+        super.destroy();
+        registry.shutdown();
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest req,
+                         HttpServletResponse resp) throws ServletException, IOException {
+        final SortedMap<String, HealthCheck.Result> results = runHealthChecks();
+        resp.setContentType(CONTENT_TYPE);
+        resp.setHeader("Cache-Control", "must-revalidate,no-cache,no-store");
+        if (results.isEmpty()) {
+            resp.setStatus(HttpServletResponse.SC_NOT_IMPLEMENTED);
+        } else {
+            final String reqParameter = req.getParameter(HTTP_STATUS_INDICATOR_PARAM);
+            final boolean httpStatusIndicatorParam = Boolean.parseBoolean(reqParameter);
+            final boolean useHttpStatusForHealthCheck = reqParameter == null ? httpStatusIndicator : httpStatusIndicatorParam;
+            if (!useHttpStatusForHealthCheck || isAllHealthy(results)) {
+                resp.setStatus(HttpServletResponse.SC_OK);
+            } else {
+                resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+            }
+        }
+
+        try (OutputStream output = resp.getOutputStream()) {
+            getWriter(req).writeValue(output, results);
+        }
+    }
+
+    private ObjectWriter getWriter(HttpServletRequest request) {
+        final boolean prettyPrint = Boolean.parseBoolean(request.getParameter("pretty"));
+        if (prettyPrint) {
+            return mapper.writerWithDefaultPrettyPrinter();
+        }
+        return mapper.writer();
+    }
+
+    private SortedMap<String, HealthCheck.Result> runHealthChecks() {
+        if (executorService == null) {
+            return registry.runHealthChecks(filter);
+        }
+        return registry.runHealthChecks(executorService, filter);
+    }
+
+    private static boolean isAllHealthy(Map<String, HealthCheck.Result> results) {
+        for (HealthCheck.Result result : results.values()) {
+            if (!result.isHealthy()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    // visible for testing
+    ObjectMapper getMapper() {
+        return mapper;
+    }
+}
diff --git a/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/MetricsServlet.java b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/MetricsServlet.java
new file mode 100644
index 0000000..a248dd8
--- /dev/null
+++ b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/MetricsServlet.java
@@ -0,0 +1,198 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.json.MetricsModule;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.util.JSONPObject;
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletContextEvent;
+import jakarta.servlet.ServletContextListener;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A servlet which returns the metrics in a given registry as an {@code application/json} response.
+ */
+public class MetricsServlet extends HttpServlet {
+    /**
+     * An abstract {@link ServletContextListener} which allows you to programmatically inject the
+     * {@link MetricRegistry}, rate and duration units, and allowed origin for
+     * {@link MetricsServlet}.
+     */
+    public static abstract class ContextListener implements ServletContextListener {
+        /**
+         * @return the {@link MetricRegistry} to inject into the servlet context.
+         */
+        protected abstract MetricRegistry getMetricRegistry();
+
+        /**
+         * @return the {@link TimeUnit} to which rates should be converted, or {@code null} if the
+         * default should be used.
+         */
+        protected TimeUnit getRateUnit() {
+            // use the default
+            return null;
+        }
+
+        /**
+         * @return the {@link TimeUnit} to which durations should be converted, or {@code null} if
+         * the default should be used.
+         */
+        protected TimeUnit getDurationUnit() {
+            // use the default
+            return null;
+        }
+
+        /**
+         * @return the {@code Access-Control-Allow-Origin} header value, if any.
+         */
+        protected String getAllowedOrigin() {
+            // use the default
+            return null;
+        }
+
+        /**
+         * Returns the name of the parameter used to specify the jsonp callback, if any.
+         */
+        protected String getJsonpCallbackParameter() {
+            return null;
+        }
+
+        /**
+         * Returns the {@link MetricFilter} that shall be used to filter metrics, or {@link MetricFilter#ALL} if
+         * the default should be used.
+         */
+        protected MetricFilter getMetricFilter() {
+            // use the default
+            return MetricFilter.ALL;
+        }
+
+        @Override
+        public void contextInitialized(ServletContextEvent event) {
+            final ServletContext context = event.getServletContext();
+            context.setAttribute(METRICS_REGISTRY, getMetricRegistry());
+            context.setAttribute(METRIC_FILTER, getMetricFilter());
+            if (getDurationUnit() != null) {
+                context.setInitParameter(MetricsServlet.DURATION_UNIT, getDurationUnit().toString());
+            }
+            if (getRateUnit() != null) {
+                context.setInitParameter(MetricsServlet.RATE_UNIT, getRateUnit().toString());
+            }
+            if (getAllowedOrigin() != null) {
+                context.setInitParameter(MetricsServlet.ALLOWED_ORIGIN, getAllowedOrigin());
+            }
+            if (getJsonpCallbackParameter() != null) {
+                context.setAttribute(CALLBACK_PARAM, getJsonpCallbackParameter());
+            }
+        }
+
+        @Override
+        public void contextDestroyed(ServletContextEvent event) {
+            // no-op
+        }
+    }
+
+    public static final String RATE_UNIT = MetricsServlet.class.getCanonicalName() + ".rateUnit";
+    public static final String DURATION_UNIT = MetricsServlet.class.getCanonicalName() + ".durationUnit";
+    public static final String SHOW_SAMPLES = MetricsServlet.class.getCanonicalName() + ".showSamples";
+    public static final String METRICS_REGISTRY = MetricsServlet.class.getCanonicalName() + ".registry";
+    public static final String ALLOWED_ORIGIN = MetricsServlet.class.getCanonicalName() + ".allowedOrigin";
+    public static final String METRIC_FILTER = MetricsServlet.class.getCanonicalName() + ".metricFilter";
+    public static final String CALLBACK_PARAM = MetricsServlet.class.getCanonicalName() + ".jsonpCallback";
+
+    private static final long serialVersionUID = 1049773947734939602L;
+    private static final String CONTENT_TYPE = "application/json";
+
+    protected String allowedOrigin;
+    protected String jsonpParamName;
+    protected transient MetricRegistry registry;
+    protected transient ObjectMapper mapper;
+
+    public MetricsServlet() {
+    }
+
+    public MetricsServlet(MetricRegistry registry) {
+        this.registry = registry;
+    }
+
+    @Override
+    public void init(ServletConfig config) throws ServletException {
+        super.init(config);
+
+        final ServletContext context = config.getServletContext();
+        if (null == registry) {
+            final Object registryAttr = context.getAttribute(METRICS_REGISTRY);
+            if (registryAttr instanceof MetricRegistry) {
+                this.registry = (MetricRegistry) registryAttr;
+            } else {
+                throw new ServletException("Couldn't find a MetricRegistry instance.");
+            }
+        }
+        this.allowedOrigin = context.getInitParameter(ALLOWED_ORIGIN);
+        this.jsonpParamName = context.getInitParameter(CALLBACK_PARAM);
+
+        setupMetricsModule(context);
+    }
+
+    protected void setupMetricsModule(ServletContext context) {
+        final TimeUnit rateUnit = parseTimeUnit(context.getInitParameter(RATE_UNIT),
+                TimeUnit.SECONDS);
+        final TimeUnit durationUnit = parseTimeUnit(context.getInitParameter(DURATION_UNIT),
+                TimeUnit.SECONDS);
+        final boolean showSamples = Boolean.parseBoolean(context.getInitParameter(SHOW_SAMPLES));
+        MetricFilter filter = (MetricFilter) context.getAttribute(METRIC_FILTER);
+        if (filter == null) {
+            filter = MetricFilter.ALL;
+        }
+
+        this.mapper = new ObjectMapper().registerModule(new MetricsModule(rateUnit,
+                durationUnit,
+                showSamples,
+                filter));
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest req,
+                         HttpServletResponse resp) throws ServletException, IOException {
+        resp.setContentType(CONTENT_TYPE);
+        if (allowedOrigin != null) {
+            resp.setHeader("Access-Control-Allow-Origin", allowedOrigin);
+        }
+        resp.setHeader("Cache-Control", "must-revalidate,no-cache,no-store");
+        resp.setStatus(HttpServletResponse.SC_OK);
+
+        try (OutputStream output = resp.getOutputStream()) {
+            if (jsonpParamName != null && req.getParameter(jsonpParamName) != null) {
+                getWriter(req).writeValue(output, new JSONPObject(req.getParameter(jsonpParamName), registry));
+            } else {
+                getWriter(req).writeValue(output, registry);
+            }
+        }
+    }
+
+    protected ObjectWriter getWriter(HttpServletRequest request) {
+        final boolean prettyPrint = Boolean.parseBoolean(request.getParameter("pretty"));
+        if (prettyPrint) {
+            return mapper.writerWithDefaultPrettyPrinter();
+        }
+        return mapper.writer();
+    }
+
+    protected TimeUnit parseTimeUnit(String value, TimeUnit defaultValue) {
+        try {
+            return TimeUnit.valueOf(String.valueOf(value).toUpperCase(Locale.US));
+        } catch (IllegalArgumentException e) {
+            return defaultValue;
+        }
+    }
+}
diff --git a/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/PingServlet.java b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/PingServlet.java
new file mode 100644
index 0000000..74bacec
--- /dev/null
+++ b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/PingServlet.java
@@ -0,0 +1,31 @@
+package io.dropwizard.metrics.servlets;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+
+/**
+ * An HTTP servlets which outputs a {@code text/plain} {@code "pong"} response.
+ */
+public class PingServlet extends HttpServlet {
+    private static final long serialVersionUID = 3772654177231086757L;
+    private static final String CONTENT_TYPE = "text/plain";
+    private static final String CONTENT = "pong";
+    private static final String CACHE_CONTROL = "Cache-Control";
+    private static final String NO_CACHE = "must-revalidate,no-cache,no-store";
+
+    @Override
+    protected void doGet(HttpServletRequest req,
+                         HttpServletResponse resp) throws ServletException, IOException {
+        resp.setStatus(HttpServletResponse.SC_OK);
+        resp.setHeader(CACHE_CONTROL, NO_CACHE);
+        resp.setContentType(CONTENT_TYPE);
+        try (PrintWriter writer = resp.getWriter()) {
+            writer.println(CONTENT);
+        }
+    }
+}
diff --git a/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/ThreadDumpServlet.java b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/ThreadDumpServlet.java
new file mode 100644
index 0000000..ee1fea3
--- /dev/null
+++ b/metrics-jakarta-servlets/src/main/java/io/dropwizard/metrics/servlets/ThreadDumpServlet.java
@@ -0,0 +1,55 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.jvm.ThreadDump;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.management.ManagementFactory;
+
+/**
+ * An HTTP servlets which outputs a {@code text/plain} dump of all threads in
+ * the VM. Only responds to {@code GET} requests.
+ */
+public class ThreadDumpServlet extends HttpServlet {
+
+    private static final long serialVersionUID = -2690343532336103046L;
+    private static final String CONTENT_TYPE = "text/plain";
+
+    private transient ThreadDump threadDump;
+
+    @Override
+    public void init() throws ServletException {
+        try {
+            // Some PaaS like Google App Engine blacklist java.lang.managament
+            this.threadDump = new ThreadDump(ManagementFactory.getThreadMXBean());
+        } catch (NoClassDefFoundError ncdfe) {
+            this.threadDump = null; // we won't be able to provide thread dump
+        }
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest req,
+                         HttpServletResponse resp) throws ServletException, IOException {
+        final boolean includeMonitors = getParam(req.getParameter("monitors"), true);
+        final boolean includeSynchronizers = getParam(req.getParameter("synchronizers"), true);
+
+        resp.setStatus(HttpServletResponse.SC_OK);
+        resp.setContentType(CONTENT_TYPE);
+        resp.setHeader("Cache-Control", "must-revalidate,no-cache,no-store");
+        if (threadDump == null) {
+            resp.getWriter().println("Sorry your runtime environment does not allow to dump threads.");
+            return;
+        }
+        try (OutputStream output = resp.getOutputStream()) {
+            threadDump.dump(includeMonitors, includeSynchronizers, output);
+        }
+    }
+
+    private static Boolean getParam(String initParam, boolean defaultValue) {
+        return initParam == null ? defaultValue : Boolean.parseBoolean(initParam);
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AbstractServletTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AbstractServletTest.java
new file mode 100644
index 0000000..3afb8bd
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AbstractServletTest.java
@@ -0,0 +1,29 @@
+package io.dropwizard.metrics.servlets;
+
+import org.eclipse.jetty.http.HttpTester;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.After;
+import org.junit.Before;
+
+public abstract class AbstractServletTest {
+    private final ServletTester tester = new ServletTester();
+    protected final HttpTester.Request request = HttpTester.newRequest();
+    protected HttpTester.Response response;
+
+    @Before
+    public void setUpTester() throws Exception {
+        setUp(tester);
+        tester.start();
+    }
+
+    protected abstract void setUp(ServletTester tester);
+
+    @After
+    public void tearDownTester() throws Exception {
+        tester.stop();
+    }
+
+    protected void processRequest() throws Exception {
+        this.response = HttpTester.parseResponse(tester.getResponses(request.generate()));
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AdminServletTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AdminServletTest.java
new file mode 100755
index 0000000..102e0a8
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AdminServletTest.java
@@ -0,0 +1,62 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.health.HealthCheckRegistry;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AdminServletTest extends AbstractServletTest {
+    private final MetricRegistry registry = new MetricRegistry();
+    private final HealthCheckRegistry healthCheckRegistry = new HealthCheckRegistry();
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.setContextPath("/context");
+
+        tester.setAttribute("io.dropwizard.metrics.servlets.MetricsServlet.registry", registry);
+        tester.setAttribute("io.dropwizard.metrics.servlets.HealthCheckServlet.registry", healthCheckRegistry);
+        tester.addServlet(AdminServlet.class, "/admin");
+    }
+
+    @Before
+    public void setUp() {
+        request.setMethod("GET");
+        request.setURI("/context/admin");
+        request.setVersion("HTTP/1.0");
+    }
+
+    @Test
+    public void returnsA200() throws Exception {
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.getContent())
+                .isEqualTo(String.format(
+                        "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"%n" +
+                                "        \"http://www.w3.org/TR/html4/loose.dtd\">%n" +
+                                "<html>%n" +
+                                "<head>%n" +
+                                "  <title>Metrics</title>%n" +
+                                "</head>%n" +
+                                "<body>%n" +
+                                "  <h1>Operational Menu</h1>%n" +
+                                "  <ul>%n" +
+                                "    <li><a href=\"/context/admin/metrics?pretty=true\">Metrics</a></li>%n" +
+                                "    <li><a href=\"/context/admin/ping\">Ping</a></li>%n" +
+                                "    <li><a href=\"/context/admin/threads\">Threads</a></li>%n" +
+                                "    <li><a href=\"/context/admin/healthcheck?pretty=true\">Healthcheck</a></li>%n" +
+                                "    <li><a href=\"/context/admin/pprof\">CPU Profile</a></li>%n" +
+                                "    <li><a href=\"/context/admin/pprof?state=blocked\">CPU Contention</a></li>%n" +
+                                "  </ul>%n" +
+                                "</body>%n" +
+                                "</html>%n"
+                ));
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("text/html;charset=UTF-8");
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AdminServletUriTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AdminServletUriTest.java
new file mode 100755
index 0000000..d3d8df5
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/AdminServletUriTest.java
@@ -0,0 +1,67 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.health.HealthCheckRegistry;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AdminServletUriTest extends AbstractServletTest {
+    private final MetricRegistry registry = new MetricRegistry();
+    private final HealthCheckRegistry healthCheckRegistry = new HealthCheckRegistry();
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.setContextPath("/context");
+
+        tester.setAttribute("io.dropwizard.metrics.servlets.MetricsServlet.registry", registry);
+        tester.setAttribute("io.dropwizard.metrics.servlets.HealthCheckServlet.registry", healthCheckRegistry);
+        tester.setInitParameter("metrics-uri", "/metrics-test");
+        tester.setInitParameter("ping-uri", "/ping-test");
+        tester.setInitParameter("threads-uri", "/threads-test");
+        tester.setInitParameter("healthcheck-uri", "/healthcheck-test");
+        tester.setInitParameter("cpu-profile-uri", "/pprof-test");
+        tester.addServlet(AdminServlet.class, "/admin");
+    }
+
+    @Before
+    public void setUp() {
+        request.setMethod("GET");
+        request.setURI("/context/admin");
+        request.setVersion("HTTP/1.0");
+    }
+
+    @Test
+    public void returnsA200() throws Exception {
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.getContent())
+                .isEqualTo(String.format(
+                        "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"%n" +
+                                "        \"http://www.w3.org/TR/html4/loose.dtd\">%n" +
+                                "<html>%n" +
+                                "<head>%n" +
+                                "  <title>Metrics</title>%n" +
+                                "</head>%n" +
+                                "<body>%n" +
+                                "  <h1>Operational Menu</h1>%n" +
+                                "  <ul>%n" +
+                                "    <li><a href=\"/context/admin/metrics-test?pretty=true\">Metrics</a></li>%n" +
+                                "    <li><a href=\"/context/admin/ping-test\">Ping</a></li>%n" +
+                                "    <li><a href=\"/context/admin/threads-test\">Threads</a></li>%n" +
+                                "    <li><a href=\"/context/admin/healthcheck-test?pretty=true\">Healthcheck</a></li>%n" +
+                                "    <li><a href=\"/context/admin/pprof-test\">CPU Profile</a></li>%n" +
+                                "    <li><a href=\"/context/admin/pprof-test?state=blocked\">CPU Contention</a></li>%n" +
+                                "  </ul>%n" +
+                                "</body>%n" +
+                                "</html>%n"
+                ));
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("text/html;charset=UTF-8");
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/CpuProfileServletTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/CpuProfileServletTest.java
new file mode 100644
index 0000000..e724acf
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/CpuProfileServletTest.java
@@ -0,0 +1,44 @@
+package io.dropwizard.metrics.servlets;
+
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class CpuProfileServletTest extends AbstractServletTest {
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.addServlet(CpuProfileServlet.class, "/pprof");
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        request.setMethod("GET");
+        request.setURI("/pprof?duration=1");
+        request.setVersion("HTTP/1.0");
+
+        processRequest();
+    }
+
+    @Test
+    public void returns200OK() {
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+    }
+
+    @Test
+    public void returnsPprofRaw() {
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("pprof/raw");
+    }
+
+    @Test
+    public void returnsUncacheable() {
+        assertThat(response.get(HttpHeader.CACHE_CONTROL))
+                .isEqualTo("must-revalidate,no-cache,no-store");
+
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/HealthCheckServletTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/HealthCheckServletTest.java
new file mode 100644
index 0000000..ec5b080
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/HealthCheckServletTest.java
@@ -0,0 +1,255 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.health.HealthCheck;
+import com.codahale.metrics.health.HealthCheckFilter;
+import com.codahale.metrics.health.HealthCheckRegistry;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class HealthCheckServletTest extends AbstractServletTest {
+
+    private static final ZonedDateTime FIXED_TIME = ZonedDateTime.now();
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+
+    private static final String EXPECTED_TIMESTAMP = DATE_TIME_FORMATTER.format(FIXED_TIME);
+
+    private static final Clock FIXED_CLOCK = new Clock() {
+        @Override
+        public long getTick() {
+            return 0L;
+        }
+
+        @Override
+        public long getTime() {
+            return FIXED_TIME.toInstant().toEpochMilli();
+        }
+    };
+
+    private final HealthCheckRegistry registry = new HealthCheckRegistry();
+    private final ExecutorService threadPool = Executors.newCachedThreadPool();
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.addServlet(io.dropwizard.metrics.servlets.HealthCheckServlet.class, "/healthchecks");
+        tester.setAttribute("io.dropwizard.metrics.servlets.HealthCheckServlet.registry", registry);
+        tester.setAttribute("io.dropwizard.metrics.servlets.HealthCheckServlet.executor", threadPool);
+        tester.setAttribute("io.dropwizard.metrics.servlets.HealthCheckServlet.healthCheckFilter",
+                (HealthCheckFilter) (name, healthCheck) -> !"filtered".equals(name));
+    }
+
+    @Before
+    public void setUp() {
+        request.setMethod("GET");
+        request.setURI("/healthchecks");
+        request.setVersion("HTTP/1.0");
+    }
+
+    @After
+    public void tearDown() {
+        threadPool.shutdown();
+    }
+
+    @Test
+    public void returns501IfNoHealthChecksAreRegistered() throws Exception {
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(501);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent()).isEqualTo("{}");
+    }
+
+    @Test
+    public void returnsA200IfAllHealthChecksAreHealthy() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
+
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent())
+                .isEqualTo("{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" +
+                        EXPECTED_TIMESTAMP +
+                        "\"}}");
+    }
+
+    @Test
+    public void returnsASubsetOfHealthChecksIfFiltered() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
+        registry.register("filtered", new TestHealthCheck(() -> unhealthyResultWithMessage("whee")));
+
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent())
+                .isEqualTo("{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" +
+                        EXPECTED_TIMESTAMP +
+                        "\"}}");
+    }
+
+    @Test
+    public void returnsA500IfAnyHealthChecksAreUnhealthy() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
+        registry.register("notFun", new TestHealthCheck(() -> unhealthyResultWithMessage("whee")));
+
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(500);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent()).contains(
+                        "{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}",
+                        ",\"notFun\":{\"healthy\":false,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}}");
+    }
+
+    @Test
+    public void returnsA200IfAnyHealthChecksAreUnhealthyAndHttpStatusIndicatorIsDisabled() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
+        registry.register("notFun", new TestHealthCheck(() -> unhealthyResultWithMessage("whee")));
+        request.setURI("/healthchecks?httpStatusIndicator=false");
+
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent()).contains(
+                "{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}",
+                ",\"notFun\":{\"healthy\":false,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}}");
+    }
+
+    @Test
+    public void optionallyPrettyPrintsTheJson() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("foo bar 123")));
+
+        request.setURI("/healthchecks?pretty=true");
+
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent())
+                .isEqualTo(String.format("{%n" +
+                        "  \"fun\" : {%n" +
+                        "    \"healthy\" : true,%n" +
+                        "    \"message\" : \"foo bar 123\",%n" +
+                        "    \"duration\" : 0,%n" +
+                        "    \"timestamp\" : \"" + EXPECTED_TIMESTAMP + "\"" +
+                        "%n  }%n}"));
+    }
+
+    private static HealthCheck.Result healthyResultWithMessage(String message) {
+        return HealthCheck.Result.builder()
+                .healthy()
+                .withMessage(message)
+                .usingClock(FIXED_CLOCK)
+                .build();
+    }
+
+    private static HealthCheck.Result unhealthyResultWithMessage(String message) {
+        return HealthCheck.Result.builder()
+                .unhealthy()
+                .withMessage(message)
+                .usingClock(FIXED_CLOCK)
+                .build();
+    }
+
+    @Test
+    public void constructorWithRegistryAsArgumentIsUsedInPreferenceOverServletConfig() throws Exception {
+        final HealthCheckRegistry healthCheckRegistry = mock(HealthCheckRegistry.class);
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+
+        final io.dropwizard.metrics.servlets.HealthCheckServlet healthCheckServlet = new io.dropwizard.metrics.servlets.HealthCheckServlet(healthCheckRegistry);
+        healthCheckServlet.init(servletConfig);
+
+        verify(servletConfig, times(1)).getServletContext();
+        verify(servletContext, never()).getAttribute(eq(io.dropwizard.metrics.servlets.HealthCheckServlet.HEALTH_CHECK_REGISTRY));
+    }
+
+    @Test
+    public void constructorWithRegistryAsArgumentUsesServletConfigWhenNull() throws Exception {
+        final HealthCheckRegistry healthCheckRegistry = mock(HealthCheckRegistry.class);
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+        when(servletContext.getAttribute(eq(io.dropwizard.metrics.servlets.HealthCheckServlet.HEALTH_CHECK_REGISTRY)))
+                .thenReturn(healthCheckRegistry);
+
+        final io.dropwizard.metrics.servlets.HealthCheckServlet healthCheckServlet = new io.dropwizard.metrics.servlets.HealthCheckServlet(null);
+        healthCheckServlet.init(servletConfig);
+
+        verify(servletConfig, times(1)).getServletContext();
+        verify(servletContext, times(1)).getAttribute(eq(io.dropwizard.metrics.servlets.HealthCheckServlet.HEALTH_CHECK_REGISTRY));
+    }
+
+    @Test(expected = ServletException.class)
+    public void constructorWithRegistryAsArgumentUsesServletConfigWhenNullButWrongTypeInContext() throws Exception {
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+        when(servletContext.getAttribute(eq(io.dropwizard.metrics.servlets.HealthCheckServlet.HEALTH_CHECK_REGISTRY)))
+                .thenReturn("IRELLEVANT_STRING");
+
+        final io.dropwizard.metrics.servlets.HealthCheckServlet healthCheckServlet = new HealthCheckServlet(null);
+        healthCheckServlet.init(servletConfig);
+    }
+
+    @Test
+    public void constructorWithObjectMapperAsArgumentUsesServletConfigWhenNullButWrongTypeInContext() throws Exception {
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+        when(servletContext.getAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY)).thenReturn(registry);
+        when(servletContext.getAttribute(HealthCheckServlet.HEALTH_CHECK_MAPPER)).thenReturn("IRELLEVANT_STRING");
+
+        final HealthCheckServlet healthCheckServlet = new HealthCheckServlet(null);
+        healthCheckServlet.init(servletConfig);
+
+        assertThat(healthCheckServlet.getMapper())
+                .isNotNull()
+                .isInstanceOf(ObjectMapper.class);
+    }
+
+    static class TestHealthCheck extends HealthCheck {
+        private final Callable<Result> check;
+
+        public TestHealthCheck(Callable<Result> check) {
+            this.check = check;
+        }
+
+        @Override
+        protected Result check() throws Exception {
+            return check.call();
+        }
+
+        @Override
+        protected Clock clock() {
+            return FIXED_CLOCK;
+        }
+    }
+
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/MetricsServletContextListenerTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/MetricsServletContextListenerTest.java
new file mode 100644
index 0000000..49ffb1c
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/MetricsServletContextListenerTest.java
@@ -0,0 +1,171 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class MetricsServletContextListenerTest extends AbstractServletTest {
+    private final Clock clock = mock(Clock.class);
+    private final MetricRegistry registry = new MetricRegistry();
+    private final String allowedOrigin = "some.other.origin";
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.setAttribute("io.dropwizard.metrics.servlets.MetricsServlet.registry", registry);
+        tester.addServlet(io.dropwizard.metrics.servlets.MetricsServlet.class, "/metrics");
+        tester.getContext().addEventListener(new MetricsServlet.ContextListener() {
+            @Override
+            protected MetricRegistry getMetricRegistry() {
+                return registry;
+            }
+
+            @Override
+            protected TimeUnit getDurationUnit() {
+                return TimeUnit.MILLISECONDS;
+            }
+
+            @Override
+            protected TimeUnit getRateUnit() {
+                return TimeUnit.MINUTES;
+            }
+
+            @Override
+            protected String getAllowedOrigin() {
+                return allowedOrigin;
+            }
+        });
+    }
+
+    @Before
+    public void setUp() {
+        // provide ticks for the setup (calls getTick 6 times). The serialization in the tests themselves
+        // will call getTick again several times and always get the same value (the last specified here)
+        when(clock.getTick()).thenReturn(100L, 100L, 200L, 300L, 300L, 400L);
+
+        registry.register("g1", (Gauge<Long>) () -> 100L);
+        registry.counter("c").inc();
+        registry.histogram("h").update(1);
+        registry.register("m", new Meter(clock)).mark();
+        registry.register("t", new Timer(new ExponentiallyDecayingReservoir(), clock))
+                .update(1, TimeUnit.SECONDS);
+
+        request.setMethod("GET");
+        request.setURI("/metrics");
+        request.setVersion("HTTP/1.0");
+    }
+
+    @Test
+    public void returnsA200() throws Exception {
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.get("Access-Control-Allow-Origin"))
+                .isEqualTo(allowedOrigin);
+        assertThat(response.getContent())
+                .isEqualTo("{" +
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{" +
+                        "\"g1\":{\"value\":100}" +
+                        "}," +
+                        "\"counters\":{" +
+                        "\"c\":{\"count\":1}" +
+                        "}," +
+                        "\"histograms\":{" +
+                        "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
+                        "}," +
+                        "\"meters\":{" +
+                        "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":2.0E8,\"units\":\"events/minute\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1000.0,\"mean\":1000.0,\"min\":1000.0,\"p50\":1000.0,\"p75\":1000.0,\"p95\":1000.0,\"p98\":1000.0,\"p99\":1000.0,\"p999\":1000.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":6.0E8,\"duration_units\":\"milliseconds\",\"rate_units\":\"calls/minute\"}" +
+                        "}" +
+                        "}");
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("application/json");
+    }
+
+    @Test
+    public void optionallyPrettyPrintsTheJson() throws Exception {
+        request.setURI("/metrics?pretty=true");
+
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.get("Access-Control-Allow-Origin"))
+                .isEqualTo(allowedOrigin);
+        assertThat(response.getContent())
+                .isEqualTo(String.format("{%n" +
+                        "  \"version\" : \"4.0.0\",%n" +
+                        "  \"gauges\" : {%n" +
+                        "    \"g1\" : {%n" +
+                        "      \"value\" : 100%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"counters\" : {%n" +
+                        "    \"c\" : {%n" +
+                        "      \"count\" : 1%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"histograms\" : {%n" +
+                        "    \"h\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"max\" : 1,%n" +
+                        "      \"mean\" : 1.0,%n" +
+                        "      \"min\" : 1,%n" +
+                        "      \"p50\" : 1.0,%n" +
+                        "      \"p75\" : 1.0,%n" +
+                        "      \"p95\" : 1.0,%n" +
+                        "      \"p98\" : 1.0,%n" +
+                        "      \"p99\" : 1.0,%n" +
+                        "      \"p999\" : 1.0,%n" +
+                        "      \"stddev\" : 0.0%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"meters\" : {%n" +
+                        "    \"m\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"m15_rate\" : 0.0,%n" +
+                        "      \"m1_rate\" : 0.0,%n" +
+                        "      \"m5_rate\" : 0.0,%n" +
+                        "      \"mean_rate\" : 2.0E8,%n" +
+                        "      \"units\" : \"events/minute\"%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"timers\" : {%n" +
+                        "    \"t\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"max\" : 1000.0,%n" +
+                        "      \"mean\" : 1000.0,%n" +
+                        "      \"min\" : 1000.0,%n" +
+                        "      \"p50\" : 1000.0,%n" +
+                        "      \"p75\" : 1000.0,%n" +
+                        "      \"p95\" : 1000.0,%n" +
+                        "      \"p98\" : 1000.0,%n" +
+                        "      \"p99\" : 1000.0,%n" +
+                        "      \"p999\" : 1000.0,%n" +
+                        "      \"stddev\" : 0.0,%n" +
+                        "      \"m15_rate\" : 0.0,%n" +
+                        "      \"m1_rate\" : 0.0,%n" +
+                        "      \"m5_rate\" : 0.0,%n" +
+                        "      \"mean_rate\" : 6.0E8,%n" +
+                        "      \"duration_units\" : \"milliseconds\",%n" +
+                        "      \"rate_units\" : \"calls/minute\"%n" +
+                        "    }%n" +
+                        "  }%n" +
+                        "}"));
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("application/json");
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/MetricsServletTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/MetricsServletTest.java
new file mode 100644
index 0000000..c70a16f
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/MetricsServletTest.java
@@ -0,0 +1,263 @@
+package io.dropwizard.metrics.servlets;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class MetricsServletTest extends AbstractServletTest {
+    private final Clock clock = mock(Clock.class);
+    private final MetricRegistry registry = new MetricRegistry();
+    private ServletTester tester;
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        this.tester = tester;
+        tester.setAttribute("io.dropwizard.metrics.servlets.MetricsServlet.registry", registry);
+        tester.addServlet(io.dropwizard.metrics.servlets.MetricsServlet.class, "/metrics");
+        tester.getContext().setInitParameter("io.dropwizard.metrics.servlets.MetricsServlet.allowedOrigin", "*");
+    }
+
+    @Before
+    public void setUp() {
+        // provide ticks for the setup (calls getTick 6 times). The serialization in the tests themselves
+        // will call getTick again several times and always get the same value (the last specified here)
+        when(clock.getTick()).thenReturn(100L, 100L, 200L, 300L, 300L, 400L);
+
+        registry.register("g1", (Gauge<Long>) () -> 100L);
+        registry.counter("c").inc();
+        registry.histogram("h").update(1);
+        registry.register("m", new Meter(clock)).mark();
+        registry.register("t", new Timer(new ExponentiallyDecayingReservoir(), clock))
+                .update(1, TimeUnit.SECONDS);
+
+        request.setMethod("GET");
+        request.setURI("/metrics");
+        request.setVersion("HTTP/1.0");
+    }
+
+    @Test
+    public void returnsA200() throws Exception {
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.get("Access-Control-Allow-Origin"))
+                .isEqualTo("*");
+        assertThat(response.getContent())
+                .isEqualTo("{" +
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{" +
+                        "\"g1\":{\"value\":100}" +
+                        "}," +
+                        "\"counters\":{" +
+                        "\"c\":{\"count\":1}" +
+                        "}," +
+                        "\"histograms\":{" +
+                        "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
+                        "}," +
+                        "\"meters\":{" +
+                        "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
+                        "}" +
+                        "}");
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("application/json");
+    }
+
+    @Test
+    public void returnsJsonWhenJsonpInitParamNotSet() throws Exception {
+        String callbackParamName = "callbackParam";
+        String callbackParamVal = "callbackParamVal";
+        request.setURI("/metrics?" + callbackParamName + "=" + callbackParamVal);
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.get("Access-Control-Allow-Origin"))
+                .isEqualTo("*");
+        assertThat(response.getContent())
+                .isEqualTo("{" +
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{" +
+                        "\"g1\":{\"value\":100}" +
+                        "}," +
+                        "\"counters\":{" +
+                        "\"c\":{\"count\":1}" +
+                        "}," +
+                        "\"histograms\":{" +
+                        "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
+                        "}," +
+                        "\"meters\":{" +
+                        "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
+                        "}" +
+                        "}");
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("application/json");
+    }
+
+    @Test
+    public void returnsJsonpWhenInitParamSet() throws Exception {
+        String callbackParamName = "callbackParam";
+        String callbackParamVal = "callbackParamVal";
+        request.setURI("/metrics?" + callbackParamName + "=" + callbackParamVal);
+        tester.getContext().setInitParameter("io.dropwizard.metrics.servlets.MetricsServlet.jsonpCallback", callbackParamName);
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get("Access-Control-Allow-Origin"))
+                .isEqualTo("*");
+        assertThat(response.getContent())
+                .isEqualTo(callbackParamVal + "({" +
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{" +
+                        "\"g1\":{\"value\":100}" +
+                        "}," +
+                        "\"counters\":{" +
+                        "\"c\":{\"count\":1}" +
+                        "}," +
+                        "\"histograms\":{" +
+                        "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
+                        "}," +
+                        "\"meters\":{" +
+                        "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
+                        "}" +
+                        "})");
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("application/json");
+    }
+
+    @Test
+    public void optionallyPrettyPrintsTheJson() throws Exception {
+        request.setURI("/metrics?pretty=true");
+
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.get("Access-Control-Allow-Origin"))
+                .isEqualTo("*");
+        assertThat(response.getContent())
+                .isEqualTo(String.format("{%n" +
+                        "  \"version\" : \"4.0.0\",%n" +
+                        "  \"gauges\" : {%n" +
+                        "    \"g1\" : {%n" +
+                        "      \"value\" : 100%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"counters\" : {%n" +
+                        "    \"c\" : {%n" +
+                        "      \"count\" : 1%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"histograms\" : {%n" +
+                        "    \"h\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"max\" : 1,%n" +
+                        "      \"mean\" : 1.0,%n" +
+                        "      \"min\" : 1,%n" +
+                        "      \"p50\" : 1.0,%n" +
+                        "      \"p75\" : 1.0,%n" +
+                        "      \"p95\" : 1.0,%n" +
+                        "      \"p98\" : 1.0,%n" +
+                        "      \"p99\" : 1.0,%n" +
+                        "      \"p999\" : 1.0,%n" +
+                        "      \"stddev\" : 0.0%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"meters\" : {%n" +
+                        "    \"m\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"m15_rate\" : 0.0,%n" +
+                        "      \"m1_rate\" : 0.0,%n" +
+                        "      \"m5_rate\" : 0.0,%n" +
+                        "      \"mean_rate\" : 3333333.3333333335,%n" +
+                        "      \"units\" : \"events/second\"%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"timers\" : {%n" +
+                        "    \"t\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"max\" : 1.0,%n" +
+                        "      \"mean\" : 1.0,%n" +
+                        "      \"min\" : 1.0,%n" +
+                        "      \"p50\" : 1.0,%n" +
+                        "      \"p75\" : 1.0,%n" +
+                        "      \"p95\" : 1.0,%n" +
+                        "      \"p98\" : 1.0,%n" +
+                        "      \"p99\" : 1.0,%n" +
+                        "      \"p999\" : 1.0,%n" +
+                        "      \"stddev\" : 0.0,%n" +
+                        "      \"m15_rate\" : 0.0,%n" +
+                        "      \"m1_rate\" : 0.0,%n" +
+                        "      \"m5_rate\" : 0.0,%n" +
+                        "      \"mean_rate\" : 1.0E7,%n" +
+                        "      \"duration_units\" : \"seconds\",%n" +
+                        "      \"rate_units\" : \"calls/second\"%n" +
+                        "    }%n" +
+                        "  }%n" +
+                        "}"));
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("application/json");
+    }
+
+    @Test
+    public void constructorWithRegistryAsArgumentIsUsedInPreferenceOverServletConfig() throws Exception {
+        final MetricRegistry metricRegistry = mock(MetricRegistry.class);
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+
+        final io.dropwizard.metrics.servlets.MetricsServlet metricsServlet = new io.dropwizard.metrics.servlets.MetricsServlet(metricRegistry);
+        metricsServlet.init(servletConfig);
+
+        verify(servletConfig, times(1)).getServletContext();
+        verify(servletContext, never()).getAttribute(eq(io.dropwizard.metrics.servlets.MetricsServlet.METRICS_REGISTRY));
+    }
+
+    @Test
+    public void constructorWithRegistryAsArgumentUsesServletConfigWhenNull() throws Exception {
+        final MetricRegistry metricRegistry = mock(MetricRegistry.class);
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+        when(servletContext.getAttribute(eq(io.dropwizard.metrics.servlets.MetricsServlet.METRICS_REGISTRY)))
+                .thenReturn(metricRegistry);
+
+        final io.dropwizard.metrics.servlets.MetricsServlet metricsServlet = new io.dropwizard.metrics.servlets.MetricsServlet(null);
+        metricsServlet.init(servletConfig);
+
+        verify(servletConfig, times(1)).getServletContext();
+        verify(servletContext, times(1)).getAttribute(eq(io.dropwizard.metrics.servlets.MetricsServlet.METRICS_REGISTRY));
+    }
+
+    @Test(expected = ServletException.class)
+    public void constructorWithRegistryAsArgumentUsesServletConfigWhenNullButWrongTypeInContext() throws Exception {
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+        when(servletContext.getAttribute(eq(io.dropwizard.metrics.servlets.MetricsServlet.METRICS_REGISTRY)))
+                .thenReturn("IRELLEVANT_STRING");
+
+        final io.dropwizard.metrics.servlets.MetricsServlet metricsServlet = new MetricsServlet(null);
+        metricsServlet.init(servletConfig);
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/PingServletTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/PingServletTest.java
new file mode 100644
index 0000000..a068560
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/PingServletTest.java
@@ -0,0 +1,49 @@
+package io.dropwizard.metrics.servlets;
+
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class PingServletTest extends AbstractServletTest {
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.addServlet(PingServlet.class, "/ping");
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        request.setMethod("GET");
+        request.setURI("/ping");
+        request.setVersion("HTTP/1.0");
+
+        processRequest();
+    }
+
+    @Test
+    public void returns200OK() {
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+    }
+
+    @Test
+    public void returnsPong() {
+        assertThat(response.getContent())
+                .isEqualTo(String.format("pong%n"));
+    }
+
+    @Test
+    public void returnsTextPlain() {
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("text/plain;charset=ISO-8859-1");
+    }
+
+    @Test
+    public void returnsUncacheable() {
+        assertThat(response.get(HttpHeader.CACHE_CONTROL))
+                .isEqualTo("must-revalidate,no-cache,no-store");
+
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/ThreadDumpServletTest.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/ThreadDumpServletTest.java
new file mode 100644
index 0000000..af4db51
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/ThreadDumpServletTest.java
@@ -0,0 +1,49 @@
+package io.dropwizard.metrics.servlets;
+
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ThreadDumpServletTest extends AbstractServletTest {
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.addServlet(ThreadDumpServlet.class, "/threads");
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        request.setMethod("GET");
+        request.setURI("/threads");
+        request.setVersion("HTTP/1.0");
+
+        processRequest();
+    }
+
+    @Test
+    public void returns200OK() {
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+    }
+
+    @Test
+    public void returnsAThreadDump() {
+        assertThat(response.getContent())
+                .contains("Finalizer");
+    }
+
+    @Test
+    public void returnsTextPlain() {
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("text/plain");
+    }
+
+    @Test
+    public void returnsUncacheable() {
+        assertThat(response.get(HttpHeader.CACHE_CONTROL))
+                .isEqualTo("must-revalidate,no-cache,no-store");
+
+    }
+}
diff --git a/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/experiments/ExampleServer.java b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/experiments/ExampleServer.java
new file mode 100644
index 0000000..5fb5db4
--- /dev/null
+++ b/metrics-jakarta-servlets/src/test/java/io/dropwizard/metrics/servlets/experiments/ExampleServer.java
@@ -0,0 +1,60 @@
+package io.dropwizard.metrics.servlets.experiments;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.health.HealthCheckRegistry;
+import io.dropwizard.metrics.jetty11.InstrumentedConnectionFactory;
+import io.dropwizard.metrics.jetty11.InstrumentedHandler;
+import io.dropwizard.metrics.jetty11.InstrumentedQueuedThreadPool;
+import io.dropwizard.metrics.servlets.AdminServlet;
+import io.dropwizard.metrics.servlets.HealthCheckServlet;
+import io.dropwizard.metrics.servlets.MetricsServlet;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.util.thread.ThreadPool;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+public class ExampleServer {
+    private static final MetricRegistry REGISTRY = new MetricRegistry();
+    private static final Counter COUNTER_1 = REGISTRY.counter(name(ExampleServer.class, "wah", "doody"));
+    private static final Counter COUNTER_2 = REGISTRY.counter(name(ExampleServer.class, "woo"));
+
+    static {
+        REGISTRY.register(name(ExampleServer.class, "boo"), (Gauge<Integer>) () -> {
+            throw new RuntimeException("asplode!");
+        });
+    }
+
+    public static void main(String[] args) throws Exception {
+        COUNTER_1.inc();
+        COUNTER_2.inc();
+
+        final ThreadPool threadPool = new InstrumentedQueuedThreadPool(REGISTRY);
+        final Server server = new Server(threadPool);
+
+        final Connector connector = new ServerConnector(server, new InstrumentedConnectionFactory(
+                new HttpConnectionFactory(), REGISTRY.timer("http.connection")));
+        server.addConnector(connector);
+
+        final ServletContextHandler context = new ServletContextHandler();
+        context.setContextPath("/initial");
+        context.setAttribute(MetricsServlet.METRICS_REGISTRY, REGISTRY);
+        context.setAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY, new HealthCheckRegistry());
+
+        final ServletHolder holder = new ServletHolder(new AdminServlet());
+        context.addServlet(holder, "/dingo/*");
+
+        final InstrumentedHandler handler = new InstrumentedHandler(REGISTRY);
+        handler.setHandler(context);
+        server.setHandler(handler);
+
+        server.start();
+        server.join();
+    }
+}
diff --git a/metrics-jcache/pom.xml b/metrics-jcache/pom.xml
index ec2ac54..5594dfb 100644
--- a/metrics-jcache/pom.xml
+++ b/metrics-jcache/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-jcache</artifactId>
@@ -16,22 +16,98 @@
         Uses the CacheStatisticsMXBean provided statistics.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.jcache</javaModuleName>
+        <cache-api.version>1.1.1</cache-api.version>
+        <ehcache3.version>3.10.8</ehcache3.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-jvm</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
         </dependency>
         <dependency>
             <groupId>javax.cache</groupId>
             <artifactId>cache-api</artifactId>
-            <version>1.0.0</version>
+            <version>${cache-api.version}</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.ehcache</groupId>
             <artifactId>ehcache</artifactId>
-            <version>3.1.3</version>
+            <version>${ehcache3.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.glassfish.jaxb</groupId>
+                    <artifactId>jaxb-runtime</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>javax.cache</groupId>
+                    <artifactId>cache-api</artifactId>
+                </exclusion>
+            </exclusions>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>javax.xml.bind</groupId>
+            <artifactId>jaxb-api</artifactId>
+            <version>2.3.1</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jaxb</groupId>
+            <artifactId>jaxb-runtime</artifactId>
+            <version>2.3.9</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>javax.activation</groupId>
+            <artifactId>activation</artifactId>
+            <version>1.1.1</version>
             <scope>test</scope>
         </dependency>
     </dependencies>
diff --git a/metrics-jcache/src/main/java/com/codahale/metrics/jcache/JCacheGaugeSet.java b/metrics-jcache/src/main/java/com/codahale/metrics/jcache/JCacheGaugeSet.java
index e21e844..47634fa 100644
--- a/metrics-jcache/src/main/java/com/codahale/metrics/jcache/JCacheGaugeSet.java
+++ b/metrics-jcache/src/main/java/com/codahale/metrics/jcache/JCacheGaugeSet.java
@@ -3,7 +3,7 @@ package com.codahale.metrics.jcache;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import com.codahale.metrics.JmxAttributeGauge;
+import com.codahale.metrics.jvm.JmxAttributeGauge;
 import com.codahale.metrics.Metric;
 import com.codahale.metrics.MetricSet;
 
@@ -41,7 +41,7 @@ public class JCacheGaugeSet implements MetricSet {
         Set<ObjectInstance> cacheBeans = getCacheBeans();
         List<String> availableStatsNames = retrieveStatsNames();
 
-        Map<String, Metric> gauges = new HashMap<String, Metric>(cacheBeans.size() * availableStatsNames.size());
+        Map<String, Metric> gauges = new HashMap<>(cacheBeans.size() * availableStatsNames.size());
 
         for (ObjectInstance cacheBean : cacheBeans) {
             ObjectName objectName = cacheBean.getObjectName();
@@ -59,8 +59,7 @@ public class JCacheGaugeSet implements MetricSet {
     private Set<ObjectInstance> getCacheBeans() {
         try {
             return ManagementFactory.getPlatformMBeanServer().queryMBeans(ObjectName.getInstance(M_BEAN_COORDINATES), null);
-        }
-        catch(MalformedObjectNameException e) {
+        } catch (MalformedObjectNameException e) {
             LOGGER.error("Unable to retrieve {}. Are JCache statistics enabled?", M_BEAN_COORDINATES);
             throw new RuntimeException(e);
         }
@@ -68,11 +67,11 @@ public class JCacheGaugeSet implements MetricSet {
 
     private List<String> retrieveStatsNames() {
         Method[] methods = CacheStatisticsMXBean.class.getDeclaredMethods();
-        List<String> availableStatsNames = new ArrayList<String>(methods.length);
+        List<String> availableStatsNames = new ArrayList<>(methods.length);
 
         for (Method method : methods) {
             String methodName = method.getName();
-            if(methodName.startsWith("get")) {
+            if (methodName.startsWith("get")) {
                 availableStatsNames.add(methodName.substring(3));
             }
         }
diff --git a/metrics-jcache/src/test/java/JCacheGaugeSetTest.java b/metrics-jcache/src/test/java/JCacheGaugeSetTest.java
index c331c58..48a026e 100644
--- a/metrics-jcache/src/test/java/JCacheGaugeSetTest.java
+++ b/metrics-jcache/src/test/java/JCacheGaugeSetTest.java
@@ -24,8 +24,8 @@ public class JCacheGaugeSetTest {
 
         CachingProvider provider = Caching.getCachingProvider();
         cacheManager = provider.getCacheManager(
-            getClass().getResource("ehcache.xml").toURI(),
-            getClass().getClassLoader());
+                getClass().getResource("ehcache.xml").toURI(),
+                getClass().getClassLoader());
 
         myCache = cacheManager.getCache("myCache");
         myOtherCache = cacheManager.getCache("myOtherCache");
@@ -39,41 +39,41 @@ public class JCacheGaugeSetTest {
 
         myOtherCache.get("woo");
         assertThat(registry.getGauges().get("jcache.statistics.myOtherCache.cache-misses").getValue())
-            .isEqualTo(1L);
+                .isEqualTo(1L);
 
         myCache.get("woo");
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-misses").getValue())
-            .isEqualTo(1L);
+                .isEqualTo(1L);
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-hits").getValue())
-            .isEqualTo(0L);
+                .isEqualTo(0L);
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-gets").getValue())
-            .isEqualTo(1L);
+                .isEqualTo(1L);
 
         myCache.put("woo", "whee");
         myCache.get("woo");
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-puts").getValue())
-            .isEqualTo(1L);
+                .isEqualTo(1L);
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-hits").getValue())
-            .isEqualTo(1L);
+                .isEqualTo(1L);
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-hit-percentage").getValue())
-            .isEqualTo(50.0f);
+                .isEqualTo(50.0f);
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-miss-percentage").getValue())
-            .isEqualTo(50.0f);
+                .isEqualTo(50.0f);
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-gets").getValue())
-            .isEqualTo(2L);
+                .isEqualTo(2L);
 
         // cache size being 1, eviction occurs after this line
         myCache.put("woo2", "whoza");
         assertThat(registry.getGauges().get("jcache.statistics.myCache.cache-evictions").getValue())
-            .isEqualTo(1L);
+                .isEqualTo(1L);
 
         myCache.remove("woo2");
-        assertThat((Float)registry.getGauges().get("jcache.statistics.myCache.average-get-time").getValue())
-            .isGreaterThan(0.0f);
-        assertThat((Float)registry.getGauges().get("jcache.statistics.myCache.average-put-time").getValue())
-            .isGreaterThan(0.0f);
-        assertThat((Float)registry.getGauges().get("jcache.statistics.myCache.average-remove-time").getValue())
-            .isGreaterThan(0.0f);
+        assertThat((Float) registry.getGauges().get("jcache.statistics.myCache.average-get-time").getValue())
+                .isGreaterThan(0.0f);
+        assertThat((Float) registry.getGauges().get("jcache.statistics.myCache.average-put-time").getValue())
+                .isGreaterThan(0.0f);
+        assertThat((Float) registry.getGauges().get("jcache.statistics.myCache.average-remove-time").getValue())
+                .isGreaterThan(0.0f);
 
     }
 
diff --git a/metrics-jcache/src/test/resources/ehcache.xml b/metrics-jcache/src/test/resources/ehcache.xml
index eacfd2b..048f260 100644
--- a/metrics-jcache/src/test/resources/ehcache.xml
+++ b/metrics-jcache/src/test/resources/ehcache.xml
@@ -1,16 +1,16 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <config xmlns="http://www.ehcache.org/v3" xmlns:jsr107="http://www.ehcache.org/v3/jsr107">
-  <service>
-    <jsr107:defaults enable-management="true" enable-statistics="true"/>
-  </service>
-  <cache-template name="simple">
-    <expiry>
-      <ttl unit="seconds">3600</ttl>
-    </expiry>
-    <heap>1</heap>
-  </cache-template>
+    <service>
+        <jsr107:defaults enable-management="true" enable-statistics="true"/>
+    </service>
+    <cache-template name="simple">
+        <expiry>
+            <ttl unit="seconds">3600</ttl>
+        </expiry>
+        <heap>1</heap>
+    </cache-template>
 
-  <cache alias="myCache" uses-template="simple"/>
-  <cache alias="myOtherCache" uses-template="simple"/>
+    <cache alias="myCache" uses-template="simple"/>
+    <cache alias="myOtherCache" uses-template="simple"/>
 
 </config>
\ No newline at end of file
diff --git a/metrics-jcstress/findbugs-exclude.xml b/metrics-jcstress/findbugs-exclude.xml
deleted file mode 100644
index 097bbe3..0000000
--- a/metrics-jcstress/findbugs-exclude.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-<FindBugsFilter>
-    <Match>
-        <Package name="com.codahale.metrics" />
-    </Match>
-    <Match>
-        <Package name="org.openjdk.jcstress.infra.results" />
-    </Match>
-</FindBugsFilter>
diff --git a/metrics-jcstress/pom.xml b/metrics-jcstress/pom.xml
index 1fba6f9..7c9ace5 100644
--- a/metrics-jcstress/pom.xml
+++ b/metrics-jcstress/pom.xml
@@ -4,11 +4,10 @@
     <parent>
         <artifactId>metrics-parent</artifactId>
         <groupId>io.dropwizard.metrics</groupId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-jcstress</artifactId>
-    <version>3.2.6</version>
     <packaging>jar</packaging>
 
     <name>Metrics JCStress tests</name>
@@ -18,10 +17,6 @@
        Edit as needed.
     -->
 
-    <prerequisites>
-        <maven>3.0</maven>
-    </prerequisites>
-
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
@@ -41,7 +36,7 @@
         <!--
             jcstress version to use with this project.
           -->
-        <jcstress.version>0.1.1</jcstress.version>
+        <jcstress.version>0.16</jcstress.version>
 
         <!--
             Java source/target to use for compilation.
@@ -52,34 +47,19 @@
             Name of the test Uber-JAR to generate.
           -->
         <uberjar.name>jcstress</uberjar.name>
+
+        <javaModuleName>com.codahale.metrics.jcstress</javaModuleName>
+        <jar.skipIfEmpty>true</jar.skipIfEmpty>
+        <maven.install.skip>true</maven.install.skip>
+        <maven.deploy.skip>true</maven.deploy.skip>
     </properties>
 
     <build>
         <plugins>
-            <plugin>
-                <!-- don't deploy this -->
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-deploy-plugin</artifactId>
-                <version>2.7</version>
-                <configuration>
-                    <skip>true</skip>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-compiler-plugin</artifactId>
-                <version>3.1</version>
-                <configuration>
-                    <compilerVersion>${javac.target}</compilerVersion>
-                    <source>${javac.target}</source>
-                    <target>${javac.target}</target>
-                </configuration>
-            </plugin>
-
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-shade-plugin</artifactId>
-                <version>2.2</version>
+                <version>3.5.1</version>
                 <executions>
                     <execution>
                         <id>main</id>
@@ -101,14 +81,6 @@
                     </execution>
                 </executions>
             </plugin>
-            <plugin>
-                <!-- exclude jmh generated classes designed to trick jvm like "unused fields" padding -->
-                <groupId>org.codehaus.mojo</groupId>
-                <artifactId>findbugs-maven-plugin</artifactId>
-                <configuration>
-                    <excludeFilterFile>findbugs-exclude.xml</excludeFilterFile>
-                </configuration>
-            </plugin>
         </plugins>
     </build>
 
diff --git a/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTrimReadTest.java b/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTrimReadTest.java
index 3132265..458aefc 100644
--- a/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTrimReadTest.java
+++ b/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirTrimReadTest.java
@@ -5,7 +5,7 @@ import org.openjdk.jcstress.annotations.Expect;
 import org.openjdk.jcstress.annotations.JCStressTest;
 import org.openjdk.jcstress.annotations.Outcome;
 import org.openjdk.jcstress.annotations.State;
-import org.openjdk.jcstress.infra.results.StringResult1;
+import org.openjdk.jcstress.infra.results.L_Result;
 
 import java.util.Arrays;
 import java.util.concurrent.TimeUnit;
@@ -16,23 +16,23 @@ import java.util.concurrent.atomic.AtomicLong;
     id = "\\[240, 241, 242, 243, 244, 245, 246, 247, 248, 249\\]",
     expect = Expect.ACCEPTABLE,
     desc = "Actor1 made read before Actor2 even started"
-)
+    )
 @Outcome(
     id = "\\[243, 244, 245, 246, 247, 248, 249\\]",
     expect = Expect.ACCEPTABLE,
     desc = "Actor2 made trim before Actor1 even started"
-)
+    )
 @Outcome(
     id = "\\[244, 245, 246, 247, 248, 249\\]",
     expect = Expect.ACCEPTABLE,
     desc = "Actor1 made trim, then Actor2 started trim and made startIndex change, " +
         "before Actor1 concurrent read."
-)
+    )
 @Outcome(
     id = "\\[243, 244, 245, 246, 247, 248\\]",
     expect = Expect.ACCEPTABLE,
     desc = "Actor1 made trim, then Actor2 started trim, but not finished startIndex change, before Actor1 concurrent read."
-)
+    )
 @State
 public class SlidingTimeWindowArrayReservoirTrimReadTest {
     private final AtomicLong ticks = new AtomicLong(0);
@@ -53,7 +53,7 @@ public class SlidingTimeWindowArrayReservoirTrimReadTest {
     }
 
     @Actor
-    public void actor1(StringResult1 r) {
+    public void actor1(L_Result r) {
         Snapshot snapshot = reservoir.getSnapshot();
         String stringValues = Arrays.toString(snapshot.getValues());
         r.r1 = stringValues;
diff --git a/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadAllocate.java b/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadAllocate.java
index 8b73a89..8c6883b 100644
--- a/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadAllocate.java
+++ b/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadAllocate.java
@@ -6,7 +6,7 @@ import org.openjdk.jcstress.annotations.Expect;
 import org.openjdk.jcstress.annotations.JCStressTest;
 import org.openjdk.jcstress.annotations.Outcome;
 import org.openjdk.jcstress.annotations.State;
-import org.openjdk.jcstress.infra.results.StringResult1;
+import org.openjdk.jcstress.infra.results.L_Result;
 
 import java.util.Arrays;
 import java.util.concurrent.TimeUnit;
@@ -36,7 +36,7 @@ public class SlidingTimeWindowArrayReservoirWriteReadAllocate {
     }
 
     @Arbiter
-    public void arbiter(StringResult1 r) {
+    public void arbiter(L_Result r) {
         Snapshot snapshot = reservoir.getSnapshot();
         long[] values = snapshot.getValues();
         String stringValues = Arrays.toString(Arrays.copyOfRange(values, values.length - 3, values.length));
diff --git a/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadTest.java b/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadTest.java
index 420770d..82d78ea 100644
--- a/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadTest.java
+++ b/metrics-jcstress/src/main/java/com/codahale/metrics/SlidingTimeWindowArrayReservoirWriteReadTest.java
@@ -5,7 +5,7 @@ import org.openjdk.jcstress.annotations.Expect;
 import org.openjdk.jcstress.annotations.JCStressTest;
 import org.openjdk.jcstress.annotations.Outcome;
 import org.openjdk.jcstress.annotations.State;
-import org.openjdk.jcstress.infra.results.StringResult1;
+import org.openjdk.jcstress.infra.results.L_Result;
 
 import java.util.Arrays;
 import java.util.concurrent.TimeUnit;
@@ -36,7 +36,7 @@ public class SlidingTimeWindowArrayReservoirWriteReadTest {
     }
 
     @Actor
-    public void actor3(StringResult1 r) {
+    public void actor3(L_Result r) {
         Snapshot snapshot = reservoir.getSnapshot();
         String stringValues = Arrays.toString(snapshot.getValues());
         r.r1 = stringValues;
diff --git a/metrics-jdbi/pom.xml b/metrics-jdbi/pom.xml
index b519d72..390c13c 100644
--- a/metrics-jdbi/pom.xml
+++ b/metrics-jdbi/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-jdbi</artifactId>
@@ -15,16 +15,61 @@
         A JDBI wrapper providing Metrics instrumentation of query durations and rates.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.jdbi</javaModuleName>
+        <jdbi2.version>2.78</jdbi2.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>org.jdbi</groupId>
             <artifactId>jdbi</artifactId>
-            <version>2.55</version>
+            <version>${jdbi2.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 </project>
diff --git a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/BasicSqlNameStrategy.java b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/BasicSqlNameStrategy.java
index 5f60bad..9c9fa70 100644
--- a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/BasicSqlNameStrategy.java
+++ b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/BasicSqlNameStrategy.java
@@ -3,6 +3,6 @@ package com.codahale.metrics.jdbi.strategies;
 public class BasicSqlNameStrategy extends DelegatingStatementNameStrategy {
     public BasicSqlNameStrategy() {
         super(NameStrategies.CHECK_EMPTY,
-              NameStrategies.SQL_OBJECT);
+                NameStrategies.SQL_OBJECT);
     }
 }
diff --git a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ContextNameStrategy.java b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ContextNameStrategy.java
index 849f6fc..2d2e33a 100644
--- a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ContextNameStrategy.java
+++ b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ContextNameStrategy.java
@@ -8,8 +8,8 @@ package com.codahale.metrics.jdbi.strategies;
 public class ContextNameStrategy extends DelegatingStatementNameStrategy {
     public ContextNameStrategy() {
         super(NameStrategies.CHECK_EMPTY,
-              NameStrategies.CHECK_RAW,
-              NameStrategies.CONTEXT_NAME,
-              NameStrategies.NAIVE_NAME);
+                NameStrategies.CHECK_RAW,
+                NameStrategies.CONTEXT_NAME,
+                NameStrategies.NAIVE_NAME);
     }
 }
diff --git a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/DelegatingStatementNameStrategy.java b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/DelegatingStatementNameStrategy.java
index 38c565f..7551d55 100644
--- a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/DelegatingStatementNameStrategy.java
+++ b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/DelegatingStatementNameStrategy.java
@@ -7,7 +7,7 @@ import java.util.Arrays;
 import java.util.List;
 
 public abstract class DelegatingStatementNameStrategy implements StatementNameStrategy {
-    private final List<StatementNameStrategy> strategies = new ArrayList<StatementNameStrategy>();
+    private final List<StatementNameStrategy> strategies = new ArrayList<>();
 
     protected DelegatingStatementNameStrategy(StatementNameStrategy... strategies) {
         registerStrategies(strategies);
diff --git a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NaiveNameStrategy.java b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NaiveNameStrategy.java
index 6c2f811..8405ddd 100644
--- a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NaiveNameStrategy.java
+++ b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NaiveNameStrategy.java
@@ -6,7 +6,7 @@ package com.codahale.metrics.jdbi.strategies;
 public class NaiveNameStrategy extends DelegatingStatementNameStrategy {
     public NaiveNameStrategy() {
         super(NameStrategies.CHECK_EMPTY,
-              NameStrategies.CHECK_RAW,
-              NameStrategies.NAIVE_NAME);
+                NameStrategies.CHECK_RAW,
+                NameStrategies.NAIVE_NAME);
     }
 }
diff --git a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NameStrategies.java b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NameStrategies.java
index 0885e01..b8064e5 100644
--- a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NameStrategies.java
+++ b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/NameStrategies.java
@@ -145,8 +145,8 @@ public final class NameStrategies {
             }
 
             return name(className.substring(0, dotPos),
-                        className.substring(dotPos + 1),
-                        statementName);
+                    className.substring(dotPos + 1),
+                    statementName);
         }
     }
 
diff --git a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ShortNameStrategy.java b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ShortNameStrategy.java
index 9859d0a..232c824 100644
--- a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ShortNameStrategy.java
+++ b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/ShortNameStrategy.java
@@ -13,7 +13,7 @@ import static com.codahale.metrics.MetricRegistry.name;
  * by class name and method; a shortening strategy is applied to make the JMX output nicer.
  */
 public final class ShortNameStrategy extends DelegatingStatementNameStrategy {
-    private final ConcurrentMap<String, String> shortClassNames = new ConcurrentHashMap<String, String>();
+    private final ConcurrentMap<String, String> shortClassNames = new ConcurrentHashMap<>();
 
     private final String baseJmxName;
 
@@ -23,10 +23,10 @@ public final class ShortNameStrategy extends DelegatingStatementNameStrategy {
         // Java does not allow super (..., new ShortContextClassStrategy(), new ShortSqlObjectStrategy(), ...);
         // ==> No enclosing instance of type <xxx> is available due to some intermediate constructor invocation. Lame.
         registerStrategies(NameStrategies.CHECK_EMPTY,
-                           new ShortContextClassStrategy(),
-                           new ShortSqlObjectStrategy(),
-                           NameStrategies.CHECK_RAW,
-                           NameStrategies.NAIVE_NAME);
+                new ShortContextClassStrategy(),
+                new ShortSqlObjectStrategy(),
+                NameStrategies.CHECK_RAW,
+                NameStrategies.NAIVE_NAME);
     }
 
     private final class ShortContextClassStrategy implements StatementNameStrategy {
diff --git a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/SmartNameStrategy.java b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/SmartNameStrategy.java
index 9802497..b0948f6 100644
--- a/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/SmartNameStrategy.java
+++ b/metrics-jdbi/src/main/java/com/codahale/metrics/jdbi/strategies/SmartNameStrategy.java
@@ -5,16 +5,16 @@ package com.codahale.metrics.jdbi.strategies;
  * Adds statistics for JDBI queries that set the {@link NameStrategies#STATEMENT_CLASS} and {@link
  * NameStrategies#STATEMENT_NAME} for class based display or {@link NameStrategies#STATEMENT_GROUP}
  * and {@link NameStrategies#STATEMENT_NAME} for group based display.
- * <p/>
+ * <p>
  * Also knows how to deal with SQL Object statements.
  */
 public class SmartNameStrategy extends DelegatingStatementNameStrategy {
     public SmartNameStrategy() {
         super(NameStrategies.CHECK_EMPTY,
-              NameStrategies.CONTEXT_CLASS,
-              NameStrategies.CONTEXT_NAME,
-              NameStrategies.SQL_OBJECT,
-              NameStrategies.CHECK_RAW,
-              NameStrategies.NAIVE_NAME);
+                NameStrategies.CONTEXT_CLASS,
+                NameStrategies.CONTEXT_NAME,
+                NameStrategies.SQL_OBJECT,
+                NameStrategies.CHECK_RAW,
+                NameStrategies.NAIVE_NAME);
     }
 }
diff --git a/metrics-jdbi3/pom.xml b/metrics-jdbi3/pom.xml
new file mode 100644
index 0000000..3190352
--- /dev/null
+++ b/metrics-jdbi3/pom.xml
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jdbi3</artifactId>
+    <name>Metrics Integration for JDBI3</name>
+    <packaging>bundle</packaging>
+    <description>Provides instrumentation of Jdbi3 data access objects</description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.jdbi3</javaModuleName>
+        <jdbi3.version>3.43.0</jdbi3.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.jdbi</groupId>
+            <artifactId>jdbi3-core</artifactId>
+            <version>${jdbi3.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+</project>
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/InstrumentedSqlLogger.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/InstrumentedSqlLogger.java
new file mode 100755
index 0000000..b3e6d9d
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/InstrumentedSqlLogger.java
@@ -0,0 +1,48 @@
+package com.codahale.metrics.jdbi3;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.jdbi3.strategies.SmartNameStrategy;
+import com.codahale.metrics.jdbi3.strategies.StatementNameStrategy;
+import org.jdbi.v3.core.statement.SqlLogger;
+import org.jdbi.v3.core.statement.StatementContext;
+
+import java.sql.SQLException;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A {@link SqlLogger} implementation for JDBI which uses the SQL objects' class names and
+ * method names for nanosecond-precision timers.
+ */
+public class InstrumentedSqlLogger implements SqlLogger {
+    private final MetricRegistry registry;
+    private final StatementNameStrategy statementNameStrategy;
+
+    public InstrumentedSqlLogger(MetricRegistry registry) {
+        this(registry, new SmartNameStrategy());
+    }
+
+    public InstrumentedSqlLogger(MetricRegistry registry,
+                                 StatementNameStrategy statementNameStrategy) {
+        this.registry = registry;
+        this.statementNameStrategy = statementNameStrategy;
+    }
+
+    @Override
+    public void logAfterExecution(StatementContext context) {
+        log(context);
+    }
+
+    @Override
+    public void logException(StatementContext context, SQLException ex) {
+        log(context);
+    }
+
+    private void log(StatementContext context) {
+        String statementName = statementNameStrategy.getStatementName(context);
+        if (statementName != null) {
+            final long elapsed = context.getElapsedTime(ChronoUnit.NANOS);
+            registry.timer(statementName).update(elapsed, TimeUnit.NANOSECONDS);
+        }
+    }
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/InstrumentedTimingCollector.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/InstrumentedTimingCollector.java
new file mode 100644
index 0000000..80d03ac
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/InstrumentedTimingCollector.java
@@ -0,0 +1,41 @@
+package com.codahale.metrics.jdbi3;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.jdbi3.strategies.SmartNameStrategy;
+import com.codahale.metrics.jdbi3.strategies.StatementNameStrategy;
+import org.jdbi.v3.core.statement.SqlLogger;
+import org.jdbi.v3.core.statement.StatementContext;
+import org.jdbi.v3.core.statement.TimingCollector;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A {@link TimingCollector} implementation for JDBI which uses the SQL objects' class names and
+ * method names for millisecond-precision timers.
+ *
+ * @deprecated Use {@link InstrumentedSqlLogger} and {@link org.jdbi.v3.core.Jdbi#setSqlLogger(SqlLogger)} instead.
+ */
+@Deprecated
+public class InstrumentedTimingCollector implements TimingCollector {
+
+    private final MetricRegistry registry;
+    private final StatementNameStrategy statementNameStrategy;
+
+    public InstrumentedTimingCollector(MetricRegistry registry) {
+        this(registry, new SmartNameStrategy());
+    }
+
+    public InstrumentedTimingCollector(MetricRegistry registry,
+                                       StatementNameStrategy statementNameStrategy) {
+        this.registry = registry;
+        this.statementNameStrategy = statementNameStrategy;
+    }
+
+    @Override
+    public void collect(long elapsedTime, StatementContext ctx) {
+        String statementName = statementNameStrategy.getStatementName(ctx);
+        if (statementName != null) {
+            registry.timer(statementName).update(elapsedTime, TimeUnit.NANOSECONDS);
+        }
+    }
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/BasicSqlNameStrategy.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/BasicSqlNameStrategy.java
new file mode 100644
index 0000000..4230493
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/BasicSqlNameStrategy.java
@@ -0,0 +1,12 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+/**
+ * Collects metrics by respective SQLObject methods.
+ */
+public class BasicSqlNameStrategy extends DelegatingStatementNameStrategy {
+
+    public BasicSqlNameStrategy() {
+        super(DefaultNameStrategy.CHECK_EMPTY,
+                DefaultNameStrategy.SQL_OBJECT);
+    }
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/DefaultNameStrategy.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/DefaultNameStrategy.java
new file mode 100644
index 0000000..f6d5511
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/DefaultNameStrategy.java
@@ -0,0 +1,59 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import com.codahale.metrics.MetricRegistry;
+import org.jdbi.v3.core.extension.ExtensionMethod;
+import org.jdbi.v3.core.statement.StatementContext;
+
+/**
+ * Default strategies which build a basis of more complex strategies
+ */
+public enum DefaultNameStrategy implements StatementNameStrategy {
+
+    /**
+     * If no SQL in the context, returns `sql.empty`, otherwise falls through
+     */
+    CHECK_EMPTY {
+        @Override
+        public String getStatementName(StatementContext statementContext) {
+            final String rawSql = statementContext.getRawSql();
+            return rawSql == null || rawSql.isEmpty() ? "sql.empty" : null;
+        }
+    },
+
+    /**
+     * If there is an SQL object attached to the context, returns the name package,
+     * the class and the method on which SQL is declared. If not SQL object is attached,
+     * falls through
+     */
+    SQL_OBJECT {
+        @Override
+        public String getStatementName(StatementContext statementContext) {
+            ExtensionMethod extensionMethod = statementContext.getExtensionMethod();
+            if (extensionMethod != null) {
+                return MetricRegistry.name(extensionMethod.getType(), extensionMethod.getMethod().getName());
+            }
+            return null;
+        }
+    },
+
+    /**
+     * Returns a raw SQL in the context (even if it's not exist)
+     */
+    NAIVE_NAME {
+        @Override
+        public String getStatementName(StatementContext statementContext) {
+            return statementContext.getRawSql();
+        }
+    },
+
+    /**
+     * Returns the `sql.raw` constant
+     */
+    CONSTANT_SQL_RAW {
+        @Override
+        public String getStatementName(StatementContext statementContext) {
+            return "sql.raw";
+        }
+    }
+
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/DelegatingStatementNameStrategy.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/DelegatingStatementNameStrategy.java
new file mode 100644
index 0000000..5e911e7
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/DelegatingStatementNameStrategy.java
@@ -0,0 +1,32 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import org.jdbi.v3.core.statement.StatementContext;
+
+import java.util.Arrays;
+import java.util.List;
+
+public abstract class DelegatingStatementNameStrategy implements StatementNameStrategy {
+
+    /**
+     * Unknown SQL.
+     */
+    private static final String UNKNOWN_SQL = "sql.unknown";
+
+    private final List<StatementNameStrategy> strategies;
+
+    protected DelegatingStatementNameStrategy(StatementNameStrategy... strategies) {
+        this.strategies = Arrays.asList(strategies);
+    }
+
+    @Override
+    public String getStatementName(StatementContext statementContext) {
+        for (StatementNameStrategy strategy : strategies) {
+            final String statementName = strategy.getStatementName(statementContext);
+            if (statementName != null) {
+                return statementName;
+            }
+        }
+
+        return UNKNOWN_SQL;
+    }
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/NaiveNameStrategy.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/NaiveNameStrategy.java
new file mode 100644
index 0000000..9967504
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/NaiveNameStrategy.java
@@ -0,0 +1,12 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+/**
+ * Very simple strategy, can be used with any JDBI loader to build basic statistics.
+ */
+public class NaiveNameStrategy extends DelegatingStatementNameStrategy {
+
+    public NaiveNameStrategy() {
+        super(DefaultNameStrategy.CHECK_EMPTY,
+              DefaultNameStrategy.NAIVE_NAME);
+    }
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/SmartNameStrategy.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/SmartNameStrategy.java
new file mode 100644
index 0000000..c42804e
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/SmartNameStrategy.java
@@ -0,0 +1,13 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+/**
+ * Uses a {@link BasicSqlNameStrategy} and fallbacks to {@link DefaultNameStrategy#CONSTANT_SQL_RAW}
+ */
+public class SmartNameStrategy extends DelegatingStatementNameStrategy {
+
+    public SmartNameStrategy() {
+        super(DefaultNameStrategy.CHECK_EMPTY,
+                DefaultNameStrategy.SQL_OBJECT,
+                DefaultNameStrategy.CONSTANT_SQL_RAW);
+    }
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/StatementNameStrategy.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/StatementNameStrategy.java
new file mode 100644
index 0000000..d069eb3
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/StatementNameStrategy.java
@@ -0,0 +1,12 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import org.jdbi.v3.core.statement.StatementContext;
+
+/**
+ * Interface for strategies to statement contexts to metric names.
+ */
+@FunctionalInterface
+public interface StatementNameStrategy {
+
+    String getStatementName(StatementContext statementContext);
+}
diff --git a/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/TimedAnnotationNameStrategy.java b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/TimedAnnotationNameStrategy.java
new file mode 100644
index 0000000..e437444
--- /dev/null
+++ b/metrics-jdbi3/src/main/java/com/codahale/metrics/jdbi3/strategies/TimedAnnotationNameStrategy.java
@@ -0,0 +1,47 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.annotation.Timed;
+import org.jdbi.v3.core.extension.ExtensionMethod;
+import org.jdbi.v3.core.statement.StatementContext;
+
+import java.lang.reflect.Method;
+
+/**
+ * Takes into account the {@link Timed} annotation on extension methods
+ */
+public class TimedAnnotationNameStrategy implements StatementNameStrategy {
+
+    @Override
+    public String getStatementName(StatementContext statementContext) {
+        final ExtensionMethod extensionMethod = statementContext.getExtensionMethod();
+        if (extensionMethod == null) {
+            return null;
+        }
+
+        final Class<?> clazz = extensionMethod.getType();
+        final Timed classTimed = clazz.getAnnotation(Timed.class);
+        final Method method = extensionMethod.getMethod();
+        final Timed methodTimed = method.getAnnotation(Timed.class);
+
+        // If the method is timed, figure out the name
+        if (methodTimed != null) {
+            String methodName = methodTimed.name().isEmpty() ? method.getName() : methodTimed.name();
+            if (methodTimed.absolute()) {
+                return methodName;
+            } else {
+                // We need to check if the class has a custom timer name
+                return classTimed == null || classTimed.name().isEmpty() ?
+                        MetricRegistry.name(clazz, methodName) :
+                        MetricRegistry.name(classTimed.name(), methodName);
+            }
+        } else if (classTimed != null) {
+            // Maybe the class is timed?
+            return classTimed.name().isEmpty() ? MetricRegistry.name(clazz, method.getName()) :
+                    MetricRegistry.name(classTimed.name(), method.getName());
+        } else {
+            // No timers neither on the method or the class
+            return null;
+        }
+    }
+}
diff --git a/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/InstrumentedSqlLoggerTest.java b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/InstrumentedSqlLoggerTest.java
new file mode 100755
index 0000000..f527fb2
--- /dev/null
+++ b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/InstrumentedSqlLoggerTest.java
@@ -0,0 +1,61 @@
+package com.codahale.metrics.jdbi3;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.jdbi3.strategies.StatementNameStrategy;
+import org.jdbi.v3.core.statement.StatementContext;
+import org.junit.Test;
+
+import java.sql.SQLException;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.TimeUnit;
+
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class InstrumentedSqlLoggerTest {
+    @Test
+    public void logsExecutionTime() {
+        final MetricRegistry mockRegistry = mock(MetricRegistry.class);
+        final StatementNameStrategy mockNameStrategy = mock(StatementNameStrategy.class);
+        final InstrumentedSqlLogger logger = new InstrumentedSqlLogger(mockRegistry, mockNameStrategy);
+
+        final StatementContext mockContext = mock(StatementContext.class);
+        final Timer mockTimer = mock(Timer.class);
+
+        final String statementName = "my-fake-name";
+        final long fakeElapsed = 1234L;
+
+        when(mockNameStrategy.getStatementName(mockContext)).thenReturn(statementName);
+        when(mockRegistry.timer(statementName)).thenReturn(mockTimer);
+
+        when(mockContext.getElapsedTime(ChronoUnit.NANOS)).thenReturn(fakeElapsed);
+
+        logger.logAfterExecution(mockContext);
+
+        verify(mockTimer).update(fakeElapsed, TimeUnit.NANOSECONDS);
+    }
+
+    @Test
+    public void logsExceptionTime() {
+        final MetricRegistry mockRegistry = mock(MetricRegistry.class);
+        final StatementNameStrategy mockNameStrategy = mock(StatementNameStrategy.class);
+        final InstrumentedSqlLogger logger = new InstrumentedSqlLogger(mockRegistry, mockNameStrategy);
+
+        final StatementContext mockContext = mock(StatementContext.class);
+        final Timer mockTimer = mock(Timer.class);
+
+        final String statementName = "my-fake-name";
+        final long fakeElapsed = 1234L;
+
+        when(mockNameStrategy.getStatementName(mockContext)).thenReturn(statementName);
+        when(mockRegistry.timer(statementName)).thenReturn(mockTimer);
+
+        when(mockContext.getElapsedTime(ChronoUnit.NANOS)).thenReturn(fakeElapsed);
+
+        logger.logException(mockContext, new SQLException());
+
+        verify(mockTimer).update(fakeElapsed, TimeUnit.NANOSECONDS);
+    }
+}
diff --git a/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/AbstractStrategyTest.java b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/AbstractStrategyTest.java
new file mode 100644
index 0000000..c6fdcde
--- /dev/null
+++ b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/AbstractStrategyTest.java
@@ -0,0 +1,23 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import com.codahale.metrics.MetricRegistry;
+import org.jdbi.v3.core.statement.StatementContext;
+import org.junit.Before;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class AbstractStrategyTest {
+
+    MetricRegistry registry = new MetricRegistry();
+    StatementContext ctx = mock(StatementContext.class);
+
+    @Before
+    public void setUp() throws Exception {
+        when(ctx.getRawSql()).thenReturn("SELECT 1");
+    }
+
+    long getTimerMaxValue(String name) {
+        return registry.timer(name).getSnapshot().getMax();
+    }
+}
diff --git a/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/BasicSqlNameStrategyTest.java b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/BasicSqlNameStrategyTest.java
new file mode 100644
index 0000000..956b964
--- /dev/null
+++ b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/BasicSqlNameStrategyTest.java
@@ -0,0 +1,21 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import org.jdbi.v3.core.extension.ExtensionMethod;
+import org.junit.Test;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+public class BasicSqlNameStrategyTest extends AbstractStrategyTest {
+
+    private BasicSqlNameStrategy basicSqlNameStrategy = new BasicSqlNameStrategy();
+
+    @Test
+    public void producesMethodNameAsMetric() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(getClass(), getClass().getMethod("producesMethodNameAsMetric")));
+        String name = basicSqlNameStrategy.getStatementName(ctx);
+        assertThat(name).isEqualTo(name(getClass(), "producesMethodNameAsMetric"));
+    }
+
+}
diff --git a/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/NaiveNameStrategyTest.java b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/NaiveNameStrategyTest.java
new file mode 100644
index 0000000..076423a
--- /dev/null
+++ b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/NaiveNameStrategyTest.java
@@ -0,0 +1,17 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class NaiveNameStrategyTest extends AbstractStrategyTest {
+
+    private NaiveNameStrategy naiveNameStrategy = new NaiveNameStrategy();
+
+    @Test
+    public void producesSqlRawMetrics() throws Exception {
+        String name = naiveNameStrategy.getStatementName(ctx);
+        assertThat(name).isEqualToIgnoringCase("SELECT 1");
+    }
+
+}
diff --git a/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/SmartNameStrategyTest.java b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/SmartNameStrategyTest.java
new file mode 100644
index 0000000..4bee835
--- /dev/null
+++ b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/SmartNameStrategyTest.java
@@ -0,0 +1,69 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import com.codahale.metrics.jdbi3.InstrumentedTimingCollector;
+import org.jdbi.v3.core.extension.ExtensionMethod;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.when;
+
+public class SmartNameStrategyTest extends AbstractStrategyTest {
+
+    private StatementNameStrategy smartNameStrategy = new SmartNameStrategy();
+    private InstrumentedTimingCollector collector;
+
+    @Before
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        collector = new InstrumentedTimingCollector(registry, smartNameStrategy);
+    }
+
+    @Test
+    public void updatesTimerForSqlObjects() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(
+                new ExtensionMethod(getClass(), getClass().getMethod("updatesTimerForSqlObjects")));
+
+        collector.collect(TimeUnit.SECONDS.toNanos(1), ctx);
+
+        String name = smartNameStrategy.getStatementName(ctx);
+        assertThat(name).isEqualTo(name(getClass(), "updatesTimerForSqlObjects"));
+        assertThat(getTimerMaxValue(name)).isEqualTo(1000000000);
+    }
+
+    @Test
+    public void updatesTimerForRawSql() throws Exception {
+        collector.collect(TimeUnit.SECONDS.toNanos(2), ctx);
+
+        String name = smartNameStrategy.getStatementName(ctx);
+        assertThat(name).isEqualTo(name("sql", "raw"));
+        assertThat(getTimerMaxValue(name)).isEqualTo(2000000000);
+    }
+
+    @Test
+    public void updatesTimerForNoRawSql() throws Exception {
+        reset(ctx);
+
+        collector.collect(TimeUnit.SECONDS.toNanos(2), ctx);
+
+        String name = smartNameStrategy.getStatementName(ctx);
+        assertThat(name).isEqualTo(name("sql", "empty"));
+        assertThat(getTimerMaxValue(name)).isEqualTo(2000000000);
+    }
+
+    @Test
+    public void updatesTimerForContextClass() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(getClass(),
+                getClass().getMethod("updatesTimerForContextClass")));
+        collector.collect(TimeUnit.SECONDS.toNanos(3), ctx);
+
+        String name = smartNameStrategy.getStatementName(ctx);
+        assertThat(name).isEqualTo(name(getClass(), "updatesTimerForContextClass"));
+        assertThat(getTimerMaxValue(name)).isEqualTo(3000000000L);
+    }
+}
diff --git a/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/TimedAnnotationNameStrategyTest.java b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/TimedAnnotationNameStrategyTest.java
new file mode 100644
index 0000000..4852d3f
--- /dev/null
+++ b/metrics-jdbi3/src/test/java/com/codahale/metrics/jdbi3/strategies/TimedAnnotationNameStrategyTest.java
@@ -0,0 +1,88 @@
+package com.codahale.metrics.jdbi3.strategies;
+
+import com.codahale.metrics.annotation.Timed;
+import org.jdbi.v3.core.extension.ExtensionMethod;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+public class TimedAnnotationNameStrategyTest extends AbstractStrategyTest {
+
+    private TimedAnnotationNameStrategy timedAnnotationNameStrategy = new TimedAnnotationNameStrategy();
+
+    public interface Foo {
+
+        @Timed
+        void update();
+
+        @Timed(name = "custom-update")
+        void customUpdate();
+
+        @Timed(name = "absolute-update", absolute = true)
+        void absoluteUpdate();
+    }
+
+
+    @Timed
+    public interface Bar {
+
+        void update();
+    }
+
+    @Timed(name = "custom-bar")
+    public interface CustomBar {
+
+        @Timed(name = "find-by-id")
+        int find(String name);
+    }
+
+    public interface Dummy {
+
+        void show();
+    }
+
+    @Test
+    public void testAnnotationOnMethod() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(Foo.class, Foo.class.getMethod("update")));
+        assertThat(timedAnnotationNameStrategy.getStatementName(ctx))
+                .isEqualTo("com.codahale.metrics.jdbi3.strategies.TimedAnnotationNameStrategyTest$Foo.update");
+    }
+
+    @Test
+    public void testAnnotationOnMethodWithCustomName() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(Foo.class, Foo.class.getMethod("customUpdate")));
+        assertThat(timedAnnotationNameStrategy.getStatementName(ctx))
+                .isEqualTo("com.codahale.metrics.jdbi3.strategies.TimedAnnotationNameStrategyTest$Foo.custom-update");
+    }
+
+    @Test
+    public void testAnnotationOnMethodWithCustomAbsoluteName() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(Foo.class, Foo.class.getMethod("absoluteUpdate")));
+        assertThat(timedAnnotationNameStrategy.getStatementName(ctx)).isEqualTo("absolute-update");
+    }
+
+    @Test
+    public void testAnnotationOnClass() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(Bar.class, Bar.class.getMethod("update")));
+        assertThat(timedAnnotationNameStrategy.getStatementName(ctx))
+                .isEqualTo("com.codahale.metrics.jdbi3.strategies.TimedAnnotationNameStrategyTest$Bar.update");
+    }
+
+    @Test
+    public void testAnnotationOnMethodAndClassWithCustomNames() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(CustomBar.class, CustomBar.class.getMethod("find", String.class)));
+        assertThat(timedAnnotationNameStrategy.getStatementName(ctx)).isEqualTo("custom-bar.find-by-id");
+    }
+
+    @Test
+    public void testNoAnnotations() throws Exception {
+        when(ctx.getExtensionMethod()).thenReturn(new ExtensionMethod(Dummy.class, Dummy.class.getMethod("show")));
+        assertThat(timedAnnotationNameStrategy.getStatementName(ctx)).isNull();
+    }
+
+    @Test
+    public void testNoMethod() {
+        assertThat(timedAnnotationNameStrategy.getStatementName(ctx)).isNull();
+    }
+}
\ No newline at end of file
diff --git a/metrics-jersey/pom.xml b/metrics-jersey/pom.xml
deleted file mode 100644
index 6d02b3f..0000000
--- a/metrics-jersey/pom.xml
+++ /dev/null
@@ -1,52 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<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>io.dropwizard.metrics</groupId>
-        <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
-    </parent>
-
-    <artifactId>metrics-jersey</artifactId>
-    <name>Metrics Integration for Jersey 1.x</name>
-    <packaging>bundle</packaging>
-    <description>
-        A set of class providing Metrics integration for Jersey, the reference JAX-RS
-        implementation. This module is for the old Jersey 1.x
-    </description>
-
-    <properties>
-        <jersey.version>1.18.1</jersey.version>
-    </properties>
-
-    <dependencies>
-        <dependency>
-            <groupId>io.dropwizard.metrics</groupId>
-            <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>io.dropwizard.metrics</groupId>
-            <artifactId>metrics-annotation</artifactId>
-            <version>${project.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>com.sun.jersey</groupId>
-            <artifactId>jersey-server</artifactId>
-            <version>${jersey.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>com.sun.jersey.jersey-test-framework</groupId>
-            <artifactId>jersey-test-framework-inmemory</artifactId>
-            <version>${jersey.version}</version>
-            <scope>test</scope>
-            <exclusions>
-                <exclusion>
-                    <groupId>junit</groupId>
-                    <artifactId>junit</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-    </dependencies>
-</project>
diff --git a/metrics-jersey/src/main/java/com/codahale/metrics/jersey/InstrumentedResourceMethodDispatchAdapter.java b/metrics-jersey/src/main/java/com/codahale/metrics/jersey/InstrumentedResourceMethodDispatchAdapter.java
deleted file mode 100644
index 931fc96..0000000
--- a/metrics-jersey/src/main/java/com/codahale/metrics/jersey/InstrumentedResourceMethodDispatchAdapter.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.codahale.metrics.jersey;
-
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.SharedMetricRegistries;
-import com.sun.jersey.spi.container.ResourceMethodDispatchAdapter;
-import com.sun.jersey.spi.container.ResourceMethodDispatchProvider;
-
-import javax.ws.rs.ext.Provider;
-
-/**
- * A provider that wraps a {@link ResourceMethodDispatchProvider} in an
- * {@link InstrumentedResourceMethodDispatchProvider}
- */
-@Provider
-public class InstrumentedResourceMethodDispatchAdapter implements ResourceMethodDispatchAdapter {
-    private final MetricRegistry registry;
-
-    /**
-     * Construct a resource method dispatch adapter using the given metrics registry name.
-     *
-     * @param registryName the name of a shared metric registry
-     */
-    public InstrumentedResourceMethodDispatchAdapter(String registryName) {
-        this(SharedMetricRegistries.getOrCreate(registryName));
-    }
-
-    /**
-     * Construct a resource method dispatch adapter using the given metrics registry.
-     * <p/>
-     * When using this constructor, the {@link InstrumentedResourceMethodDispatchAdapter}
-     * should be added to a Jersey {@code ResourceConfig} as a singleton.
-     *
-     * @param registry a {@link MetricRegistry}
-     */
-    public InstrumentedResourceMethodDispatchAdapter(MetricRegistry registry) {
-        this.registry = registry;
-    }
-
-
-    @Override
-    public ResourceMethodDispatchProvider adapt(ResourceMethodDispatchProvider provider) {
-        return new InstrumentedResourceMethodDispatchProvider(provider, registry);
-    }
-}
diff --git a/metrics-jersey/src/main/java/com/codahale/metrics/jersey/InstrumentedResourceMethodDispatchProvider.java b/metrics-jersey/src/main/java/com/codahale/metrics/jersey/InstrumentedResourceMethodDispatchProvider.java
deleted file mode 100644
index 3530a61..0000000
--- a/metrics-jersey/src/main/java/com/codahale/metrics/jersey/InstrumentedResourceMethodDispatchProvider.java
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.codahale.metrics.jersey;
-
-import com.sun.jersey.api.core.HttpContext;
-import com.sun.jersey.api.model.AbstractResourceMethod;
-import com.sun.jersey.spi.container.ResourceMethodDispatchProvider;
-import com.sun.jersey.spi.dispatch.RequestDispatcher;
-import com.codahale.metrics.Meter;
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.Timer;
-import com.codahale.metrics.annotation.ExceptionMetered;
-import com.codahale.metrics.annotation.Metered;
-import com.codahale.metrics.annotation.Timed;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-class InstrumentedResourceMethodDispatchProvider implements ResourceMethodDispatchProvider {
-    private static class TimedRequestDispatcher implements RequestDispatcher {
-        private final RequestDispatcher underlying;
-        private final Timer timer;
-
-        private TimedRequestDispatcher(RequestDispatcher underlying, Timer timer) {
-            this.underlying = underlying;
-            this.timer = timer;
-        }
-
-        @Override
-        public void dispatch(Object resource, HttpContext httpContext) {
-            final Timer.Context context = timer.time();
-            try {
-                underlying.dispatch(resource, httpContext);
-            } finally {
-                context.stop();
-            }
-        }
-    }
-
-    private static class MeteredRequestDispatcher implements RequestDispatcher {
-        private final RequestDispatcher underlying;
-        private final Meter meter;
-
-        private MeteredRequestDispatcher(RequestDispatcher underlying, Meter meter) {
-            this.underlying = underlying;
-            this.meter = meter;
-        }
-
-        @Override
-        public void dispatch(Object resource, HttpContext httpContext) {
-            meter.mark();
-            underlying.dispatch(resource, httpContext);
-        }
-    }
-
-    private static class ExceptionMeteredRequestDispatcher implements RequestDispatcher {
-        private final RequestDispatcher underlying;
-        private final Meter meter;
-        private final Class<? extends Throwable> exceptionClass;
-
-        private ExceptionMeteredRequestDispatcher(RequestDispatcher underlying,
-                                                  Meter meter,
-                                                  Class<? extends Throwable> exceptionClass) {
-            this.underlying = underlying;
-            this.meter = meter;
-            this.exceptionClass = exceptionClass;
-        }
-
-        @Override
-        public void dispatch(Object resource, HttpContext httpContext) {
-            try {
-                underlying.dispatch(resource, httpContext);
-            } catch (Exception e) {
-                if (exceptionClass.isAssignableFrom(e.getClass()) ||
-                        (e.getCause() != null && exceptionClass.isAssignableFrom(e.getCause().getClass()))) {
-                    meter.mark();
-                }
-                InstrumentedResourceMethodDispatchProvider.<RuntimeException>throwUnchecked(e);
-            }
-        }
-    }
-
-    /*
-     * A dirty hack to allow us to throw exceptions of any type without bringing down the unsafe
-     * thunder.
-     */
-    @SuppressWarnings("unchecked")
-    private static <T extends Exception> void throwUnchecked(Throwable e) throws T {
-        throw (T) e;
-    }
-
-    private final ResourceMethodDispatchProvider provider;
-    private final MetricRegistry registry;
-
-    public InstrumentedResourceMethodDispatchProvider(ResourceMethodDispatchProvider provider,
-                                                      MetricRegistry registry) {
-        this.provider = provider;
-        this.registry = registry;
-    }
-
-    @Override
-    public RequestDispatcher create(AbstractResourceMethod method) {
-        RequestDispatcher dispatcher = provider.create(method);
-        if (dispatcher == null) {
-            return null;
-        }
-
-        if (method.getMethod().isAnnotationPresent(Timed.class)) {
-            final Timed annotation = method.getMethod().getAnnotation(Timed.class);
-            final String name = chooseName(annotation.name(), annotation.absolute(), method);
-            final Timer timer = registry.timer(name);
-            dispatcher = new TimedRequestDispatcher(dispatcher, timer);
-        }
-
-        if (method.getMethod().isAnnotationPresent(Metered.class)) {
-            final Metered annotation = method.getMethod().getAnnotation(Metered.class);
-            final String name = chooseName(annotation.name(), annotation.absolute(), method);
-            final Meter meter = registry.meter(name);
-            dispatcher = new MeteredRequestDispatcher(dispatcher, meter);
-        }
-
-        if (method.getMethod().isAnnotationPresent(ExceptionMetered.class)) {
-            final ExceptionMetered annotation = method.getMethod()
-                                                      .getAnnotation(ExceptionMetered.class);
-            final String name = chooseName(annotation.name(),
-                                           annotation.absolute(),
-                                           method,
-                                           ExceptionMetered.DEFAULT_NAME_SUFFIX);
-            final Meter meter = registry.meter(name);
-            dispatcher = new ExceptionMeteredRequestDispatcher(dispatcher,
-                                                               meter,
-                                                               annotation.cause());
-        }
-
-        return dispatcher;
-    }
-
-    private String chooseName(String explicitName, boolean absolute, AbstractResourceMethod method, String... suffixes) {
-        if (explicitName != null && !explicitName.isEmpty()) {
-            if (absolute) {
-                return explicitName;
-            }
-            return name(method.getDeclaringResource().getResourceClass(), explicitName);
-        }
-        return name(name(method.getDeclaringResource().getResourceClass(),
-                         method.getMethod().getName()),
-                    suffixes);
-    }
-}
diff --git a/metrics-jersey/src/test/java/com/codahale/metrics/jersey/SingletonMetricsJerseyTest.java b/metrics-jersey/src/test/java/com/codahale/metrics/jersey/SingletonMetricsJerseyTest.java
deleted file mode 100644
index 4ff1bca..0000000
--- a/metrics-jersey/src/test/java/com/codahale/metrics/jersey/SingletonMetricsJerseyTest.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.codahale.metrics.jersey;
-
-import com.sun.jersey.api.container.MappableContainerException;
-import com.sun.jersey.api.core.DefaultResourceConfig;
-import com.sun.jersey.test.framework.AppDescriptor;
-import com.sun.jersey.test.framework.JerseyTest;
-import com.sun.jersey.test.framework.LowLevelAppDescriptor;
-import com.codahale.metrics.Meter;
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.Timer;
-import com.codahale.metrics.jersey.resources.InstrumentedResource;
-import org.junit.Test;
-
-import java.io.IOException;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-import static com.codahale.metrics.MetricRegistry.name;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown;
-
-/**
- * Tests importing {@link InstrumentedResourceMethodDispatchAdapter} as a singleton
- * in a Jersey {@link com.sun.jersey.api.core.ResourceConfig}
- */
-public class SingletonMetricsJerseyTest extends JerseyTest {
-    static {
-        Logger.getLogger("com.sun.jersey").setLevel(Level.OFF);
-    }
-
-    private MetricRegistry registry;
-
-    @Override
-    protected AppDescriptor configure() {
-        this.registry = new MetricRegistry();
-
-        final DefaultResourceConfig config = new DefaultResourceConfig();
-        config.getSingletons().add(new InstrumentedResourceMethodDispatchAdapter(registry));
-        config.getClasses().add(InstrumentedResource.class);
-
-        return new LowLevelAppDescriptor.Builder(config).build();
-    }
-
-    @Test
-    public void timedMethodsAreTimed() {
-        assertThat(resource().path("timed").get(String.class))
-                .isEqualTo("yay");
-
-        final Timer timer = registry.timer(name(InstrumentedResource.class, "timed"));
-
-        assertThat(timer.getCount())
-                .isEqualTo(1);
-    }
-
-    @Test
-    public void meteredMethodsAreMetered() {
-        assertThat(resource().path("metered").get(String.class))
-                .isEqualTo("woo");
-
-        final Meter meter = registry.meter(name(InstrumentedResource.class, "metered"));
-        assertThat(meter.getCount())
-                .isEqualTo(1);
-    }
-
-    @Test
-    public void exceptionMeteredMethodsAreExceptionMetered() {
-        final Meter meter = registry.meter(name(InstrumentedResource.class,
-                                                "exceptionMetered",
-                                                "exceptions"));
-
-        assertThat(resource().path("exception-metered").get(String.class))
-                .isEqualTo("fuh");
-
-        assertThat(meter.getCount())
-                .isZero();
-        
-        try {
-            resource().path("exception-metered").queryParam("splode", "true").get(String.class);
-            failBecauseExceptionWasNotThrown(MappableContainerException.class);
-        } catch (MappableContainerException e) {
-            assertThat(e.getCause())
-                    .isInstanceOf(IOException.class);
-        }
-
-        assertThat(meter.getCount())
-                .isEqualTo(1);
-    }
-}
diff --git a/metrics-jersey/src/test/java/com/codahale/metrics/jersey/resources/InstrumentedResource.java b/metrics-jersey/src/test/java/com/codahale/metrics/jersey/resources/InstrumentedResource.java
deleted file mode 100644
index d2cf346..0000000
--- a/metrics-jersey/src/test/java/com/codahale/metrics/jersey/resources/InstrumentedResource.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.codahale.metrics.jersey.resources;
-
-import com.codahale.metrics.annotation.ExceptionMetered;
-import com.codahale.metrics.annotation.Metered;
-import com.codahale.metrics.annotation.Timed;
-
-import javax.ws.rs.*;
-import javax.ws.rs.core.MediaType;
-import java.io.IOException;
-
-@Path("/")
-@Produces(MediaType.TEXT_PLAIN)
-public class InstrumentedResource {
-    @GET
-    @Timed
-    @Path("/timed")
-    public String timed() {
-        return "yay";
-    }
-
-    @GET
-    @Metered
-    @Path("/metered")
-    public String metered() {
-        return "woo";
-    }
-
-    @GET
-    @ExceptionMetered(cause = IOException.class)
-    @Path("/exception-metered")
-    public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
-        if (splode) {
-            throw new IOException("AUGH");
-        }
-        return "fuh";
-    }
-}
diff --git a/metrics-jersey2/pom.xml b/metrics-jersey2/pom.xml
index 8a7af11..c38ab74 100644
--- a/metrics-jersey2/pom.xml
+++ b/metrics-jersey2/pom.xml
@@ -3,62 +3,103 @@
     <modelVersion>4.0.0</modelVersion>
 
     <parent>
-	<groupId>io.dropwizard.metrics</groupId>
-	<artifactId>metrics-parent</artifactId>
-	<version>3.2.6</version>
+        <groupId>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-jersey2</artifactId>
     <name>Metrics Integration for Jersey 2.x</name>
     <packaging>bundle</packaging>
     <description>
-	A set of class providing Metrics integration for Jersey, the reference JAX-RS
-	implementation.
+        A set of class providing Metrics integration for Jersey, the reference JAX-RS
+        implementation.
     </description>
 
     <properties>
-	<jersey.version>2.11</jersey.version>
+        <javaModuleName>com.codahale.metrics.jersey2</javaModuleName>
+        <jersey.version>2.41</jersey.version>
     </properties>
 
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.glassfish.jersey</groupId>
+                <artifactId>jersey-bom</artifactId>
+                <version>${jersey.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>jakarta.annotation</groupId>
+                <artifactId>jakarta.annotation-api</artifactId>
+                <version>1.3.5</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
-	<dependency>
-	    <groupId>io.dropwizard.metrics</groupId>
-	    <artifactId>metrics-core</artifactId>
-	    <version>${project.version}</version>
-	</dependency>
-	<dependency>
-	    <groupId>io.dropwizard.metrics</groupId>
-	    <artifactId>metrics-annotation</artifactId>
-	    <version>${project.version}</version>
-	</dependency>
-	<dependency>
-		<groupId>org.glassfish.jersey.core</groupId>
-		<artifactId>jersey-server</artifactId>
-		<version>${jersey.version}</version>
-	</dependency>
-	<dependency>
-		<groupId>
-			org.glassfish.jersey.test-framework.providers
-		</groupId>
-		<artifactId>
-			jersey-test-framework-provider-inmemory
-		</artifactId>
-		<version>${jersey.version}</version>
-		<scope>test</scope>
-	</dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.ws.rs</groupId>
+            <artifactId>jakarta.ws.rs-api</artifactId>
+            <version>2.1.6</version>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.core</groupId>
+            <artifactId>jersey-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.inject</groupId>
+            <artifactId>jersey-hk2</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.core</groupId>
+            <artifactId>jersey-client</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.test-framework</groupId>
+            <artifactId>jersey-test-framework-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.test-framework.providers</groupId>
+            <artifactId>jersey-test-framework-provider-inmemory</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
-
-    <build>
-	<plugins>
-	    <plugin>
-		<groupId>org.apache.maven.plugins</groupId>
-		<artifactId>maven-compiler-plugin</artifactId>
-		<version>3.1</version>
-		<configuration>
-		    <source>1.7</source>
-		    <target>1.7</target>
-		</configuration>
-	    </plugin>
-	</plugins>
-    </build>
 </project>
diff --git a/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/InstrumentedResourceMethodApplicationListener.java b/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/InstrumentedResourceMethodApplicationListener.java
index 7454953..7697e11 100644
--- a/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/InstrumentedResourceMethodApplicationListener.java
+++ b/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/InstrumentedResourceMethodApplicationListener.java
@@ -1,11 +1,17 @@
 package com.codahale.metrics.jersey2;
 
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
 import com.codahale.metrics.Meter;
 import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Reservoir;
 import com.codahale.metrics.Timer;
 import com.codahale.metrics.annotation.ExceptionMetered;
 import com.codahale.metrics.annotation.Metered;
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
 import com.codahale.metrics.annotation.Timed;
+import org.glassfish.jersey.server.ContainerResponse;
 import org.glassfish.jersey.server.model.ModelProcessor;
 import org.glassfish.jersey.server.model.Resource;
 import org.glassfish.jersey.server.model.ResourceMethod;
@@ -19,40 +25,87 @@ import javax.ws.rs.core.Configuration;
 import javax.ws.rs.ext.Provider;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.EnumSet;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
 
 import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
 
 /**
  * An application event listener that listens for Jersey application initialization to
  * be finished, then creates a map of resource method that have metrics annotations.
- * <p/>
+ * <p>
  * Finally, it listens for method start events, and returns a {@link RequestEventListener}
  * that updates the relevant metric for suitably annotated methods when it gets the
  * request events indicating that the method is about to be invoked, or just got done
  * being invoked.
  */
-
 @Provider
 public class InstrumentedResourceMethodApplicationListener implements ApplicationEventListener, ModelProcessor {
 
+    private static final String[] REQUEST_FILTERING = {"request", "filtering"};
+    private static final String[] RESPONSE_FILTERING = {"response", "filtering"};
+    private static final String TOTAL = "total";
+
     private final MetricRegistry metrics;
-    private ConcurrentMap<Method, Timer> timers = new ConcurrentHashMap<>();
-    private ConcurrentMap<Method, Meter> meters = new ConcurrentHashMap<>();
-    private ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters = new ConcurrentHashMap<>();
+    private final ConcurrentMap<EventTypeAndMethod, Timer> timers = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, Meter> meters = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, ResponseMeterMetric> responseMeters = new ConcurrentHashMap<>();
+
+    private final Clock clock;
+    private final boolean trackFilters;
+    private final Supplier<Reservoir> reservoirSupplier;
 
     /**
      * Construct an application event listener using the given metrics registry.
-     * <p/>
-     * <p/>
+     * <p>
      * When using this constructor, the {@link InstrumentedResourceMethodApplicationListener}
      * should be added to a Jersey {@code ResourceConfig} as a singleton.
      *
      * @param metrics a {@link MetricRegistry}
      */
     public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics) {
+        this(metrics, Clock.defaultClock(), false);
+    }
+
+    /**
+     * Constructs a custom application listener.
+     *
+     * @param metrics      the metrics registry where the metrics will be stored
+     * @param clock        the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters whether the processing time for request and response filters should be tracked
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics, final Clock clock,
+                                                         final boolean trackFilters) {
+        this(metrics, clock, trackFilters, ExponentiallyDecayingReservoir::new);
+    }
+
+    /**
+     * Constructs a custom application listener.
+     *
+     * @param metrics           the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics, final Clock clock,
+                                                         final boolean trackFilters,
+                                                         final Supplier<Reservoir> reservoirSupplier) {
         this.metrics = metrics;
+        this.clock = clock;
+        this.trackFilters = trackFilters;
+        this.reservoirSupplier = reservoirSupplier;
     }
 
     /**
@@ -74,28 +127,124 @@ public class InstrumentedResourceMethodApplicationListener implements Applicatio
         }
     }
 
+    /**
+     * A private class to maintain the metrics for a method annotated with the
+     * {@link ResponseMetered} annotation, which needs to maintain meters for
+     * different response codes
+     */
+    private static class ResponseMeterMetric {
+        private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+        private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+        private final List<Meter> meters;
+        private final Map<Integer, Meter> responseCodeMeters;
+        private final MetricRegistry metricRegistry;
+        private final String metricName;
+        private final ResponseMeteredLevel level;
+
+        public ResponseMeterMetric(final MetricRegistry registry,
+                                   final ResourceMethod method,
+                                   final ResponseMetered responseMetered) {
+            this.metricName = chooseName(responseMetered.name(), responseMetered.absolute(), method);
+            this.level = responseMetered.level();
+            this.meters = COARSE_METER_LEVELS.contains(level) ?
+                    Collections.unmodifiableList(Arrays.asList(
+                    registry.meter(name(metricName, "1xx-responses")), // 1xx
+                    registry.meter(name(metricName, "2xx-responses")), // 2xx
+                    registry.meter(name(metricName, "3xx-responses")), // 3xx
+                    registry.meter(name(metricName, "4xx-responses")), // 4xx
+                    registry.meter(name(metricName, "5xx-responses"))  // 5xx
+            )) : Collections.emptyList();
+            this.responseCodeMeters = DETAILED_METER_LEVELS.contains(level) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+            this.metricRegistry = registry;
+        }
+
+        public void mark(int statusCode) {
+            if (DETAILED_METER_LEVELS.contains(level)) {
+                getResponseCodeMeter(statusCode).mark();
+            }
+
+            if (COARSE_METER_LEVELS.contains(level)) {
+                final int responseStatus = statusCode / 100;
+                if (responseStatus >= 1 && responseStatus <= 5) {
+                    meters.get(responseStatus - 1).mark();
+                }
+            }
+        }
+
+        private Meter getResponseCodeMeter(int statusCode) {
+            return responseCodeMeters
+                    .computeIfAbsent(statusCode, sc -> metricRegistry
+                            .meter(name(metricName, String.format("%d-responses", sc))));
+        }
+    }
+
     private static class TimerRequestEventListener implements RequestEventListener {
-        private final ConcurrentMap<Method, Timer> timers;
-        private Timer.Context context = null;
 
-        public TimerRequestEventListener(final ConcurrentMap<Method, Timer> timers) {
+        private final ConcurrentMap<EventTypeAndMethod, Timer> timers;
+        private final Clock clock;
+        private final long start;
+        private Timer.Context resourceMethodStartContext;
+        private Timer.Context requestMatchedContext;
+        private Timer.Context responseFiltersStartContext;
+
+        public TimerRequestEventListener(final ConcurrentMap<EventTypeAndMethod, Timer> timers, final Clock clock) {
             this.timers = timers;
+            this.clock = clock;
+            start = clock.getTick();
         }
 
         @Override
         public void onEvent(RequestEvent event) {
-            if (event.getType() == RequestEvent.Type.RESOURCE_METHOD_START) {
-                final Timer timer = this.timers.get(event.getUriInfo()
-                        .getMatchedResourceMethod().getInvocable().getDefinitionMethod());
-                if (timer != null) {
-                    this.context = timer.time();
-                }
-            } else if (event.getType() == RequestEvent.Type.RESOURCE_METHOD_FINISHED) {
-                if (this.context != null) {
-                    this.context.close();
-                }
+            switch (event.getType()) {
+                case RESOURCE_METHOD_START:
+                    resourceMethodStartContext = context(event);
+                    break;
+                case REQUEST_MATCHED:
+                    requestMatchedContext = context(event);
+                    break;
+                case RESP_FILTERS_START:
+                    responseFiltersStartContext = context(event);
+                    break;
+                case RESOURCE_METHOD_FINISHED:
+                    if (resourceMethodStartContext != null) {
+                        resourceMethodStartContext.close();
+                    }
+                    break;
+                case REQUEST_FILTERED:
+                    if (requestMatchedContext != null) {
+                        requestMatchedContext.close();
+                    }
+                    break;
+                case RESP_FILTERS_FINISHED:
+                    if (responseFiltersStartContext != null) {
+                        responseFiltersStartContext.close();
+                    }
+                    break;
+                case FINISHED:
+                    if (requestMatchedContext != null && responseFiltersStartContext != null) {
+                        final Timer timer = timer(event);
+                        if (timer != null) {
+                            timer.update(clock.getTick() - start, TimeUnit.NANOSECONDS);
+                        }
+                    }
+                    break;
+                default:
+                    break;
             }
         }
+
+        private Timer timer(RequestEvent event) {
+            final ResourceMethod resourceMethod = event.getUriInfo().getMatchedResourceMethod();
+            if (resourceMethod == null) {
+                return null;
+            }
+            return timers.get(new EventTypeAndMethod(event.getType(), resourceMethod.getInvocable().getDefinitionMethod()));
+        }
+
+        private Timer.Context context(RequestEvent event) {
+            final Timer timer = timer(event);
+            return timer != null ? timer.time() : null;
+        }
     }
 
     private static class MeterRequestEventListener implements RequestEventListener {
@@ -108,8 +257,7 @@ public class InstrumentedResourceMethodApplicationListener implements Applicatio
         @Override
         public void onEvent(RequestEvent event) {
             if (event.getType() == RequestEvent.Type.RESOURCE_METHOD_START) {
-                final Meter meter = this.meters.get(event.getUriInfo()
-                        .getMatchedResourceMethod().getInvocable().getDefinitionMethod());
+                final Meter meter = this.meters.get(event.getUriInfo().getMatchedResourceMethod().getInvocable().getDefinitionMethod());
                 if (meter != null) {
                     meter.mark();
                 }
@@ -142,6 +290,32 @@ public class InstrumentedResourceMethodApplicationListener implements Applicatio
         }
     }
 
+    private static class ResponseMeterRequestEventListener implements RequestEventListener {
+        private final ConcurrentMap<Method, ResponseMeterMetric> responseMeters;
+
+        public ResponseMeterRequestEventListener(final ConcurrentMap<Method, ResponseMeterMetric> responseMeters) {
+            this.responseMeters = responseMeters;
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            if (event.getType() == RequestEvent.Type.FINISHED) {
+                final ResourceMethod method = event.getUriInfo().getMatchedResourceMethod();
+                final ResponseMeterMetric metric = (method != null) ?
+                        this.responseMeters.get(method.getInvocable().getDefinitionMethod()) : null;
+
+                if (metric != null) {
+                    ContainerResponse containerResponse = event.getContainerResponse();
+                    if (containerResponse == null && event.getException() != null) {
+                        metric.mark(500);
+                    } else if (containerResponse != null) {
+                        metric.mark(containerResponse.getStatus());
+                    }
+                }
+            }
+        }
+    }
+
     private static class ChainedRequestEventListener implements RequestEventListener {
         private final RequestEventListener[] listeners;
 
@@ -181,11 +355,13 @@ public class InstrumentedResourceMethodApplicationListener implements Applicatio
             final Timed classLevelTimed = getClassLevelAnnotation(resource, Timed.class);
             final Metered classLevelMetered = getClassLevelAnnotation(resource, Metered.class);
             final ExceptionMetered classLevelExceptionMetered = getClassLevelAnnotation(resource, ExceptionMetered.class);
+            final ResponseMetered classLevelResponseMetered = getClassLevelAnnotation(resource, ResponseMetered.class);
 
             for (final ResourceMethod method : resource.getAllMethods()) {
                 registerTimedAnnotations(method, classLevelTimed);
                 registerMeteredAnnotations(method, classLevelMetered);
                 registerExceptionMeteredAnnotations(method, classLevelExceptionMetered);
+                registerResponseMeteredAnnotations(method, classLevelResponseMetered);
             }
 
             for (final Resource childResource : resource.getChildResources()) {
@@ -193,23 +369,25 @@ public class InstrumentedResourceMethodApplicationListener implements Applicatio
                 final Timed classLevelTimedChild = getClassLevelAnnotation(childResource, Timed.class);
                 final Metered classLevelMeteredChild = getClassLevelAnnotation(childResource, Metered.class);
                 final ExceptionMetered classLevelExceptionMeteredChild = getClassLevelAnnotation(childResource, ExceptionMetered.class);
+                final ResponseMetered classLevelResponseMeteredChild = getClassLevelAnnotation(childResource, ResponseMetered.class);
 
                 for (final ResourceMethod method : childResource.getAllMethods()) {
                     registerTimedAnnotations(method, classLevelTimedChild);
                     registerMeteredAnnotations(method, classLevelMeteredChild);
                     registerExceptionMeteredAnnotations(method, classLevelExceptionMeteredChild);
+                    registerResponseMeteredAnnotations(method, classLevelResponseMeteredChild);
                 }
             }
         }
-
     }
 
     @Override
     public RequestEventListener onRequest(final RequestEvent event) {
         final RequestEventListener listener = new ChainedRequestEventListener(
-                new TimerRequestEventListener(timers),
+                new TimerRequestEventListener(timers, clock),
                 new MeterRequestEventListener(meters),
-                new ExceptionMeterRequestEventListener(exceptionMeters));
+                new ExceptionMeterRequestEventListener(exceptionMeters),
+                new ResponseMeterRequestEventListener(responseMeters));
 
         return listener;
     }
@@ -230,14 +408,22 @@ public class InstrumentedResourceMethodApplicationListener implements Applicatio
     private void registerTimedAnnotations(final ResourceMethod method, final Timed classLevelTimed) {
         final Method definitionMethod = method.getInvocable().getDefinitionMethod();
         if (classLevelTimed != null) {
-            timers.putIfAbsent(definitionMethod, timerMetric(this.metrics, method, classLevelTimed));
+            registerTimers(method, definitionMethod, classLevelTimed);
             return;
         }
 
         final Timed annotation = definitionMethod.getAnnotation(Timed.class);
-
         if (annotation != null) {
-            timers.putIfAbsent(definitionMethod, timerMetric(this.metrics, method, annotation));
+            registerTimers(method, definitionMethod, annotation);
+        }
+    }
+
+    private void registerTimers(ResourceMethod method, Method definitionMethod, Timed annotation) {
+        timers.putIfAbsent(EventTypeAndMethod.requestMethodStart(definitionMethod), timerMetric(metrics, method, annotation));
+        if (trackFilters) {
+            timers.putIfAbsent(EventTypeAndMethod.requestMatched(definitionMethod), timerMetric(metrics, method, annotation, REQUEST_FILTERING));
+            timers.putIfAbsent(EventTypeAndMethod.respFiltersStart(definitionMethod), timerMetric(metrics, method, annotation, RESPONSE_FILTERING));
+            timers.putIfAbsent(EventTypeAndMethod.finished(definitionMethod), timerMetric(metrics, method, annotation, TOTAL));
         }
     }
 
@@ -269,30 +455,95 @@ public class InstrumentedResourceMethodApplicationListener implements Applicatio
         }
     }
 
-    private static Timer timerMetric(final MetricRegistry registry,
-                                     final ResourceMethod method,
-                                     final Timed timed) {
-        final String name = chooseName(timed.name(), timed.absolute(), method);
-        return registry.timer(name);
+    private void registerResponseMeteredAnnotations(final ResourceMethod method, final ResponseMetered classLevelResponseMetered) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+
+        if (classLevelResponseMetered != null) {
+            responseMeters.putIfAbsent(definitionMethod, new ResponseMeterMetric(metrics, method, classLevelResponseMetered));
+            return;
+        }
+        final ResponseMetered annotation = definitionMethod.getAnnotation(ResponseMetered.class);
+
+        if (annotation != null) {
+            responseMeters.putIfAbsent(definitionMethod, new ResponseMeterMetric(metrics, method, annotation));
+        }
     }
 
-    private static Meter meterMetric(final MetricRegistry registry,
-                                     final ResourceMethod method,
-                                     final Metered metered) {
+    private Timer timerMetric(final MetricRegistry registry,
+                              final ResourceMethod method,
+                              final Timed timed,
+                              final String... suffixes) {
+        final String name = chooseName(timed.name(), timed.absolute(), method, suffixes);
+        return registry.timer(name, () -> new Timer(reservoirSupplier.get(), clock));
+    }
+
+    private Meter meterMetric(final MetricRegistry registry,
+                              final ResourceMethod method,
+                              final Metered metered) {
         final String name = chooseName(metered.name(), metered.absolute(), method);
-        return registry.meter(name);
+        return registry.meter(name, () -> new Meter(clock));
     }
 
-    protected static String chooseName(final String explicitName, final boolean absolute, final ResourceMethod method, final String... suffixes) {
+    protected static String chooseName(final String explicitName, final boolean absolute, final ResourceMethod method,
+                                       final String... suffixes) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+        final String metricName;
         if (explicitName != null && !explicitName.isEmpty()) {
-            if (absolute) {
-                return explicitName;
+            metricName = absolute ? explicitName : name(definitionMethod.getDeclaringClass(), explicitName);
+        } else {
+            metricName = name(definitionMethod.getDeclaringClass(), definitionMethod.getName());
+        }
+        return name(metricName, suffixes);
+    }
+
+    private static class EventTypeAndMethod {
+
+        private final RequestEvent.Type type;
+        private final Method method;
+
+        private EventTypeAndMethod(RequestEvent.Type type, Method method) {
+            this.type = type;
+            this.method = method;
+        }
+
+        static EventTypeAndMethod requestMethodStart(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.RESOURCE_METHOD_START, method);
+        }
+
+        static EventTypeAndMethod requestMatched(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.REQUEST_MATCHED, method);
+        }
+
+        static EventTypeAndMethod respFiltersStart(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.RESP_FILTERS_START, method);
+        }
+
+        static EventTypeAndMethod finished(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.FINISHED, method);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
             }
-            return name(method.getInvocable().getDefinitionMethod().getDeclaringClass(), explicitName);
+
+            EventTypeAndMethod that = (EventTypeAndMethod) o;
+
+            if (type != that.type) {
+                return false;
+            }
+            return method.equals(that.method);
         }
 
-        return name(name(method.getInvocable().getDefinitionMethod().getDeclaringClass(),
-                        method.getInvocable().getDefinitionMethod().getName()),
-                suffixes);
+        @Override
+        public int hashCode() {
+            int result = type.hashCode();
+            result = 31 * result + method.hashCode();
+            return result;
+        }
     }
 }
diff --git a/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/MetricsFeature.java b/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/MetricsFeature.java
index a97caf9..0e65b14 100644
--- a/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/MetricsFeature.java
+++ b/metrics-jersey2/src/main/java/com/codahale/metrics/jersey2/MetricsFeature.java
@@ -1,10 +1,14 @@
 package com.codahale.metrics.jersey2;
 
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
 import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Reservoir;
 import com.codahale.metrics.SharedMetricRegistries;
 
 import javax.ws.rs.core.Feature;
 import javax.ws.rs.core.FeatureContext;
+import java.util.function.Supplier;
 
 /**
  * A {@link Feature} which registers a {@link InstrumentedResourceMethodApplicationListener}
@@ -13,9 +17,53 @@ import javax.ws.rs.core.FeatureContext;
 public class MetricsFeature implements Feature {
 
     private final MetricRegistry registry;
+    private final Clock clock;
+    private final boolean trackFilters;
+    private final Supplier<Reservoir> reservoirSupplier;
 
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     */
     public MetricsFeature(MetricRegistry registry) {
+        this(registry, Clock.defaultClock());
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public MetricsFeature(MetricRegistry registry, Supplier<Reservoir> reservoirSupplier) {
+        this(registry, Clock.defaultClock(), false, reservoirSupplier);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock) {
+        this(registry, clock, false);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock, boolean trackFilters) {
+        this(registry, clock, trackFilters, ExponentiallyDecayingReservoir::new);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock, boolean trackFilters, Supplier<Reservoir> reservoirSupplier) {
         this.registry = registry;
+        this.clock = clock;
+        this.trackFilters = trackFilters;
+        this.reservoirSupplier = reservoirSupplier;
     }
 
     public MetricsFeature(String registryName) {
@@ -25,7 +73,7 @@ public class MetricsFeature implements Feature {
     /**
      * A call-back method called when the feature is to be enabled in a given
      * runtime configuration scope.
-     * <p/>
+     * <p>
      * The responsibility of the feature is to properly update the supplied runtime configuration context
      * and return {@code true} if the feature was successfully enabled or {@code false} otherwise.
      * <p>
@@ -35,7 +83,7 @@ public class MetricsFeature implements Feature {
      * {@link javax.ws.rs.core.Configuration#isEnabled(javax.ws.rs.core.Feature)} or
      * {@link javax.ws.rs.core.Configuration#isEnabled(Class)} method
      * would return {@code false}.
-     * </p>
+     * <p>
      *
      * @param context configurable context in which the feature should be enabled.
      * @return {@code true} if the feature was successfully enabled, {@code false}
@@ -43,7 +91,7 @@ public class MetricsFeature implements Feature {
      */
     @Override
     public boolean configure(FeatureContext context) {
-        context.register(new InstrumentedResourceMethodApplicationListener(registry));
+        context.register(new InstrumentedResourceMethodApplicationListener(registry, clock, trackFilters, reservoirSupplier));
         return true;
     }
 }
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/CustomReservoirImplementationTest.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/CustomReservoirImplementationTest.java
new file mode 100644
index 0000000..77d6965
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/CustomReservoirImplementationTest.java
@@ -0,0 +1,44 @@
+package com.codahale.metrics.jersey2;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.UniformReservoir;
+import com.codahale.metrics.jersey2.resources.InstrumentedResourceTimedPerClass;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import javax.ws.rs.core.Application;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class CustomReservoirImplementationTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        return new ResourceConfig()
+                .register(new MetricsFeature(this.registry, UniformReservoir::new))
+                .register(InstrumentedResourceTimedPerClass.class);
+    }
+
+    @Test
+    public void timerHistogramIsUsingCustomReservoirImplementation() {
+        assertThat(target("timedPerClass").request().get(String.class)).isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedResourceTimedPerClass.class, "timedPerClass"));
+        assertThat(timer)
+                .extracting("histogram")
+                .extracting("reservoir")
+                .isInstanceOf(UniformReservoir.class);
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonFilterMetricsJerseyTest.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonFilterMetricsJerseyTest.java
new file mode 100644
index 0000000..ee05e7c
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonFilterMetricsJerseyTest.java
@@ -0,0 +1,162 @@
+package com.codahale.metrics.jersey2;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.jersey2.resources.InstrumentedFilteredResource;
+import com.codahale.metrics.jersey2.resources.TestRequestFilter;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.ws.rs.core.Application;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig} with filter tracking
+ */
+public class SingletonFilterMetricsJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    private TestClock testClock;
+
+    @Override
+    protected Application configure() {
+        registry = new MetricRegistry();
+        testClock = new TestClock();
+        ResourceConfig config = new ResourceConfig();
+        config = config.register(new MetricsFeature(this.registry, testClock, true));
+        config = config.register(new TestRequestFilter(testClock));
+        config = config.register(new InstrumentedFilteredResource(testClock));
+        return config;
+    }
+
+    @Before
+    public void resetClock() {
+        testClock.tick = 0;
+    }
+
+    @Test
+    public void timedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void explicitNamesAreTimed() {
+        assertThat(target("named")
+                .request()
+                .get(String.class))
+                .isEqualTo("fancy");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "fancyName"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void absoluteNamesAreTimed() {
+        assertThat(target("absolute")
+                .request()
+                .get(String.class))
+                .isEqualTo("absolute");
+
+        final Timer timer = registry.timer("absolutelyFancy");
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void requestFiltersOfTimedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "request", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void responseFiltersOfTimedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "response", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void totalTimeOfTimedMethodsIsTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "total"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(5);
+    }
+
+    @Test
+    public void requestFiltersOfNamedMethodsAreTimed() {
+        assertThat(target("named")
+                .request()
+                .get(String.class))
+                .isEqualTo("fancy");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "fancyName", "request", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void requestFiltersOfAbsoluteMethodsAreTimed() {
+        assertThat(target("absolute")
+                .request()
+                .get(String.class))
+                .isEqualTo("absolute");
+
+        final Timer timer = registry.timer(name("absolutelyFancy", "request", "filtering"));
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void subResourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.InstrumentedFilteredSubResource.class,
+                "timed"));
+        assertThat(timer.getCount()).isEqualTo(1);
+
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonMetricsJerseyTest.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonMetricsJerseyTest.java
index 4d95099..bb11703 100644
--- a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonMetricsJerseyTest.java
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonMetricsJerseyTest.java
@@ -94,6 +94,76 @@ public class SingletonMetricsJerseyTest extends JerseyTest {
         assertThat(meter.getCount()).isEqualTo(1);
     }
 
+    @Test
+    public void responseMeteredMethodsAreMeteredWithCoarseLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredCoarse",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredCoarse",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("response-metered-coarse")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isZero();
+    }
+
+    @Test
+    public void responseMeteredMethodsAreMeteredWithDetailedLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "200-responses"));
+        final Meter meter201 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "201-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(meter201.getCount()).isZero();
+        assertThat(target("response-metered-detailed")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+        assertThat(target("response-metered-detailed")
+                .queryParam("status_code", 201)
+                .request()
+                .get().getStatus())
+                .isEqualTo(201);
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isOne();
+        assertThat(meter201.getCount()).isOne();
+    }
+
+    @Test
+    public void responseMeteredMethodsAreMeteredWithAllLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredAll",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredAll",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("response-metered-all")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isOne();
+    }
+
     @Test
     public void testResourceNotFound() {
         final Response response = target().path("not-found").request().get();
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonMetricsResponseMeteredPerClassJerseyTest.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonMetricsResponseMeteredPerClassJerseyTest.java
new file mode 100644
index 0000000..ac27e0e
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/SingletonMetricsResponseMeteredPerClassJerseyTest.java
@@ -0,0 +1,146 @@
+package com.codahale.metrics.jersey2;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.jersey2.exception.mapper.TestExceptionMapper;
+import com.codahale.metrics.jersey2.resources.InstrumentedResourceResponseMeteredPerClass;
+import com.codahale.metrics.jersey2.resources.InstrumentedSubResourceResponseMeteredPerClass;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import javax.ws.rs.core.Application;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsResponseMeteredPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceResponseMeteredPerClass.class);
+        config = config.register(new TestExceptionMapper());
+
+        return config;
+    }
+
+    @Test
+    public void responseMetered2xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered2xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        final Meter meter2xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered2xxPerClass",
+                "2xx-responses"));
+
+        assertThat(meter2xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMetered4xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered4xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(400);
+        assertThat(target("responseMeteredBadRequestPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(400);
+
+        final Meter meter4xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered4xxPerClass",
+                "4xx-responses"));
+        final Meter meterException4xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredBadRequestPerClass",
+                "4xx-responses"));
+
+        assertThat(meter4xx.getCount()).isEqualTo(1);
+        assertThat(meterException4xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMetered5xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered5xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(500);
+
+        final Meter meter5xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered5xxPerClass",
+                "5xx-responses"));
+
+        assertThat(meter5xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredMappedExceptionPerClassMethodsAreMetered() {
+        assertThat(target("responseMeteredTestExceptionPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(500);
+
+        final Meter meterTestException = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredTestExceptionPerClass",
+                "5xx-responses"));
+
+        assertThat(meterTestException.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredUnmappedExceptionPerClassMethodsAreMetered() {
+        try {
+            target("responseMeteredRuntimeExceptionPerClass")
+                    .request()
+                    .get();
+            fail("expected RuntimeException");
+        } catch (Exception e) {
+            assertThat(e.getCause()).isInstanceOf(RuntimeException.class);
+        }
+
+        final Meter meterException5xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredRuntimeExceptionPerClass",
+                "5xx-responses"));
+
+        assertThat(meterException5xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        final Meter meter2xx = registry.meter(name(InstrumentedSubResourceResponseMeteredPerClass.class,
+                "responseMeteredPerClass",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedSubResourceResponseMeteredPerClass.class,
+                "responseMeteredPerClass",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("subresource/responseMeteredPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isOne();
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/TestClock.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/TestClock.java
new file mode 100644
index 0000000..1f2cf9c
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/TestClock.java
@@ -0,0 +1,13 @@
+package com.codahale.metrics.jersey2;
+
+import com.codahale.metrics.Clock;
+
+public class TestClock extends Clock {
+
+    public long tick;
+
+    @Override
+    public long getTick() {
+        return tick;
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/exception/TestException.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/exception/TestException.java
new file mode 100644
index 0000000..2b0512a
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/exception/TestException.java
@@ -0,0 +1,9 @@
+package com.codahale.metrics.jersey2.exception;
+
+public class TestException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    public TestException(String message) {
+        super(message);
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/exception/mapper/TestExceptionMapper.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/exception/mapper/TestExceptionMapper.java
new file mode 100644
index 0000000..296a054
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/exception/mapper/TestExceptionMapper.java
@@ -0,0 +1,15 @@
+package com.codahale.metrics.jersey2.exception.mapper;
+
+import com.codahale.metrics.jersey2.exception.TestException;
+
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import javax.ws.rs.ext.Provider;
+
+@Provider
+public class TestExceptionMapper implements ExceptionMapper<TestException> {
+    @Override
+    public Response toResponse(TestException exception) {
+        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedFilteredResource.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedFilteredResource.java
new file mode 100644
index 0000000..46a0be5
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedFilteredResource.java
@@ -0,0 +1,62 @@
+package com.codahale.metrics.jersey2.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import com.codahale.metrics.jersey2.TestClock;
+
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedFilteredResource {
+
+    private final TestClock testClock;
+
+    public InstrumentedFilteredResource(TestClock testClock) {
+        this.testClock = testClock;
+    }
+
+    @GET
+    @Timed
+    @Path("/timed")
+    public String timed() {
+        testClock.tick++;
+        return "yay";
+    }
+
+    @GET
+    @Timed(name = "fancyName")
+    @Path("/named")
+    public String named() {
+        testClock.tick++;
+        return "fancy";
+    }
+
+    @GET
+    @Timed(name = "absolutelyFancy", absolute = true)
+    @Path("/absolute")
+    public String absolute() {
+        testClock.tick++;
+        return "absolute";
+    }
+
+    @Path("/subresource")
+    public InstrumentedFilteredSubResource locateSubResource() {
+        return new InstrumentedFilteredSubResource();
+    }
+
+    @Produces(MediaType.TEXT_PLAIN)
+    public class InstrumentedFilteredSubResource {
+
+        @GET
+        @Timed
+        @Path("/timed")
+        public String timed() {
+            testClock.tick += 2;
+            return "yay";
+        }
+
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResource.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResource.java
index 8b7bf33..963ac82 100644
--- a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResource.java
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResource.java
@@ -2,12 +2,22 @@ package com.codahale.metrics.jersey2.resources;
 
 import com.codahale.metrics.annotation.ExceptionMetered;
 import com.codahale.metrics.annotation.Metered;
+import com.codahale.metrics.annotation.ResponseMetered;
 import com.codahale.metrics.annotation.Timed;
 
-import javax.ws.rs.*;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
 import java.io.IOException;
 
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+
 @Path("/")
 @Produces(MediaType.TEXT_PLAIN)
 public class InstrumentedResource {
@@ -35,6 +45,27 @@ public class InstrumentedResource {
         return "fuh";
     }
 
+    @GET
+    @ResponseMetered(level = DETAILED)
+    @Path("/response-metered-detailed")
+    public Response responseMeteredDetailed(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @GET
+    @ResponseMetered(level = COARSE)
+    @Path("/response-metered-coarse")
+    public Response responseMeteredCoarse(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @GET
+    @ResponseMetered(level = ALL)
+    @Path("/response-metered-all")
+    public Response responseMeteredAll(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
     @Path("/subresource")
     public InstrumentedSubResource locateSubResource() {
         return new InstrumentedSubResource();
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResourceExceptionMeteredPerClass.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResourceExceptionMeteredPerClass.java
index b5ac922..929faba 100644
--- a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResourceExceptionMeteredPerClass.java
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResourceExceptionMeteredPerClass.java
@@ -2,7 +2,11 @@ package com.codahale.metrics.jersey2.resources;
 
 import com.codahale.metrics.annotation.ExceptionMetered;
 
-import javax.ws.rs.*;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.MediaType;
 import java.io.IOException;
 
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResourceResponseMeteredPerClass.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResourceResponseMeteredPerClass.java
new file mode 100644
index 0000000..e03e8e9
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedResourceResponseMeteredPerClass.java
@@ -0,0 +1,58 @@
+package com.codahale.metrics.jersey2.resources;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.jersey2.exception.TestException;
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+@ResponseMetered
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceResponseMeteredPerClass {
+
+    @GET
+    @Path("/responseMetered2xxPerClass")
+    public Response responseMetered2xxPerClass() {
+        return Response.ok().build();
+    }
+
+    @GET
+    @Path("/responseMetered4xxPerClass")
+    public Response responseMetered4xxPerClass() {
+        return Response.status(Response.Status.BAD_REQUEST).build();
+    }
+
+    @GET
+    @Path("/responseMetered5xxPerClass")
+    public Response responseMetered5xxPerClass() {
+        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+    }
+
+    @GET
+    @Path("/responseMeteredBadRequestPerClass")
+    public String responseMeteredBadRequestPerClass() {
+        throw new BadRequestException();
+    }
+
+    @GET
+    @Path("/responseMeteredRuntimeExceptionPerClass")
+    public String responseMeteredRuntimeExceptionPerClass() {
+        throw new RuntimeException();
+    }
+
+    @GET
+    @Path("/responseMeteredTestExceptionPerClass")
+    public String responseMeteredTestExceptionPerClass() {
+        throw new TestException("test");
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceResponseMeteredPerClass locateSubResource() {
+        return new InstrumentedSubResourceResponseMeteredPerClass();
+    }
+
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResource.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResource.java
index 8ef7612..4c8f79f 100644
--- a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResource.java
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResource.java
@@ -2,12 +2,14 @@ package com.codahale.metrics.jersey2.resources;
 
 import com.codahale.metrics.annotation.Timed;
 
-import javax.ws.rs.*;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
-import java.io.IOException;
 
 @Produces(MediaType.TEXT_PLAIN)
 public class InstrumentedSubResource {
+
     @GET
     @Timed
     @Path("/timed")
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResourceExceptionMeteredPerClass.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResourceExceptionMeteredPerClass.java
index 3a120aa..fec77ad 100644
--- a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResourceExceptionMeteredPerClass.java
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResourceExceptionMeteredPerClass.java
@@ -2,7 +2,11 @@ package com.codahale.metrics.jersey2.resources;
 
 import com.codahale.metrics.annotation.ExceptionMetered;
 
-import javax.ws.rs.*;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.MediaType;
 import java.io.IOException;
 
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResourceResponseMeteredPerClass.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResourceResponseMeteredPerClass.java
new file mode 100644
index 0000000..ae131ee
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/InstrumentedSubResourceResponseMeteredPerClass.java
@@ -0,0 +1,22 @@
+package com.codahale.metrics.jersey2.resources;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+
+@ResponseMetered(level = ALL)
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceResponseMeteredPerClass {
+    @GET
+    @Path("/responseMeteredPerClass")
+    public Response responseMeteredPerClass() {
+        return Response.status(Response.Status.OK).build();
+    }
+}
diff --git a/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/TestRequestFilter.java b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/TestRequestFilter.java
new file mode 100644
index 0000000..3d6d639
--- /dev/null
+++ b/metrics-jersey2/src/test/java/com/codahale/metrics/jersey2/resources/TestRequestFilter.java
@@ -0,0 +1,21 @@
+package com.codahale.metrics.jersey2.resources;
+
+import com.codahale.metrics.jersey2.TestClock;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import java.io.IOException;
+
+public class TestRequestFilter implements ContainerRequestFilter {
+
+    private final TestClock testClock;
+
+    public TestRequestFilter(TestClock testClock) {
+        this.testClock = testClock;
+    }
+
+    @Override
+    public void filter(ContainerRequestContext containerRequestContext) throws IOException {
+        testClock.tick += 4;
+    }
+}
diff --git a/metrics-jersey3/pom.xml b/metrics-jersey3/pom.xml
new file mode 100644
index 0000000..01caffc
--- /dev/null
+++ b/metrics-jersey3/pom.xml
@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jersey3</artifactId>
+    <name>Metrics Integration for Jersey 3.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of class providing Metrics integration for Jersey, the reference JAX-RS
+        implementation.
+    </description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.jersey3</javaModuleName>
+        <jersey.version>3.0.12</jersey.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.glassfish.jersey</groupId>
+                <artifactId>jersey-bom</artifactId>
+                <version>${jersey.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>jakarta.annotation</groupId>
+                <artifactId>jakarta.annotation-api</artifactId>
+                <version>2.1.1</version>
+            </dependency>
+            <dependency>
+                <groupId>jakarta.inject</groupId>
+                <artifactId>jakarta.inject-api</artifactId>
+                <version>2.0.1</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.core</groupId>
+            <artifactId>jersey-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.ws.rs</groupId>
+            <artifactId>jakarta.ws.rs-api</artifactId>
+            <version>3.0.0</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.13.1</version>
+            <scope>test</scope>
+         </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.inject</groupId>
+            <artifactId>jersey-hk2</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.core</groupId>
+            <artifactId>jersey-client</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.test-framework.providers</groupId>
+            <artifactId>jersey-test-framework-provider-inmemory</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.test-framework</groupId>
+            <artifactId>jersey-test-framework-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jersey3/src/main/java/com/codahale/metrics/jersey3/InstrumentedResourceMethodApplicationListener.java b/metrics-jersey3/src/main/java/com/codahale/metrics/jersey3/InstrumentedResourceMethodApplicationListener.java
new file mode 100644
index 0000000..0e0b39a
--- /dev/null
+++ b/metrics-jersey3/src/main/java/com/codahale/metrics/jersey3/InstrumentedResourceMethodApplicationListener.java
@@ -0,0 +1,550 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Reservoir;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ExceptionMetered;
+import com.codahale.metrics.annotation.Metered;
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.core.Configuration;
+import jakarta.ws.rs.ext.Provider;
+import org.glassfish.jersey.server.ContainerResponse;
+import org.glassfish.jersey.server.model.ModelProcessor;
+import org.glassfish.jersey.server.model.Resource;
+import org.glassfish.jersey.server.model.ResourceMethod;
+import org.glassfish.jersey.server.model.ResourceModel;
+import org.glassfish.jersey.server.monitoring.ApplicationEvent;
+import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
+import org.glassfish.jersey.server.monitoring.RequestEvent;
+import org.glassfish.jersey.server.monitoring.RequestEventListener;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+
+/**
+ * An application event listener that listens for Jersey application initialization to
+ * be finished, then creates a map of resource method that have metrics annotations.
+ * <p>
+ * Finally, it listens for method start events, and returns a {@link RequestEventListener}
+ * that updates the relevant metric for suitably annotated methods when it gets the
+ * request events indicating that the method is about to be invoked, or just got done
+ * being invoked.
+ */
+@Provider
+public class InstrumentedResourceMethodApplicationListener implements ApplicationEventListener, ModelProcessor {
+
+    private static final String[] REQUEST_FILTERING = {"request", "filtering"};
+    private static final String[] RESPONSE_FILTERING = {"response", "filtering"};
+    private static final String TOTAL = "total";
+
+    private final MetricRegistry metrics;
+    private final ConcurrentMap<EventTypeAndMethod, Timer> timers = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, Meter> meters = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, ResponseMeterMetric> responseMeters = new ConcurrentHashMap<>();
+
+    private final Clock clock;
+    private final boolean trackFilters;
+    private final Supplier<Reservoir> reservoirSupplier;
+
+    /**
+     * Construct an application event listener using the given metrics registry.
+     * <p>
+     * When using this constructor, the {@link InstrumentedResourceMethodApplicationListener}
+     * should be added to a Jersey {@code ResourceConfig} as a singleton.
+     *
+     * @param metrics a {@link MetricRegistry}
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics) {
+        this(metrics, Clock.defaultClock(), false);
+    }
+
+    /**
+     * Constructs a custom application listener.
+     *
+     * @param metrics      the metrics registry where the metrics will be stored
+     * @param clock        the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters whether the processing time for request and response filters should be tracked
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics, final Clock clock,
+                                                         final boolean trackFilters) {
+        this(metrics, clock, trackFilters, ExponentiallyDecayingReservoir::new);
+    }
+
+    /**
+     * Constructs a custom application listener.
+     *
+     * @param metrics           the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics, final Clock clock,
+                                                         final boolean trackFilters,
+                                                         final Supplier<Reservoir> reservoirSupplier) {
+        this.metrics = metrics;
+        this.clock = clock;
+        this.trackFilters = trackFilters;
+        this.reservoirSupplier = reservoirSupplier;
+    }
+
+    /**
+     * A private class to maintain the metric for a method annotated with the
+     * {@link ExceptionMetered} annotation, which needs to maintain both a meter
+     * and a cause for which the meter should be updated.
+     */
+    private static class ExceptionMeterMetric {
+        public final Meter meter;
+        public final Class<? extends Throwable> cause;
+
+        public ExceptionMeterMetric(final MetricRegistry registry,
+                                    final ResourceMethod method,
+                                    final ExceptionMetered exceptionMetered) {
+            final String name = chooseName(exceptionMetered.name(),
+                    exceptionMetered.absolute(), method, ExceptionMetered.DEFAULT_NAME_SUFFIX);
+            this.meter = registry.meter(name);
+            this.cause = exceptionMetered.cause();
+        }
+    }
+
+    /**
+     * A private class to maintain the metrics for a method annotated with the
+     * {@link ResponseMetered} annotation, which needs to maintain meters for
+     * different response codes
+     */
+    private static class ResponseMeterMetric {
+        private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+        private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+        private final List<Meter> meters;
+        private final Map<Integer, Meter> responseCodeMeters;
+        private final MetricRegistry metricRegistry;
+        private final String metricName;
+        private final ResponseMeteredLevel level;
+
+        public ResponseMeterMetric(final MetricRegistry registry,
+                                   final ResourceMethod method,
+                                   final ResponseMetered responseMetered) {
+            this.metricName = chooseName(responseMetered.name(), responseMetered.absolute(), method);
+            this.level = responseMetered.level();
+            this.meters = COARSE_METER_LEVELS.contains(level) ?
+                    Collections.unmodifiableList(Arrays.asList(
+                            registry.meter(name(metricName, "1xx-responses")), // 1xx
+                            registry.meter(name(metricName, "2xx-responses")), // 2xx
+                            registry.meter(name(metricName, "3xx-responses")), // 3xx
+                            registry.meter(name(metricName, "4xx-responses")), // 4xx
+                            registry.meter(name(metricName, "5xx-responses"))  // 5xx
+                    )) : Collections.emptyList();
+            this.responseCodeMeters = DETAILED_METER_LEVELS.contains(level) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+            this.metricRegistry = registry;
+        }
+
+        public void mark(int statusCode) {
+            if (DETAILED_METER_LEVELS.contains(level)) {
+                getResponseCodeMeter(statusCode).mark();
+            }
+
+            if (COARSE_METER_LEVELS.contains(level)) {
+                final int responseStatus = statusCode / 100;
+                if (responseStatus >= 1 && responseStatus <= 5) {
+                    meters.get(responseStatus - 1).mark();
+                }
+            }
+        }
+
+        private Meter getResponseCodeMeter(int statusCode) {
+            return responseCodeMeters
+                    .computeIfAbsent(statusCode, sc -> metricRegistry
+                            .meter(name(metricName, String.format("%d-responses", sc))));
+        }
+    }
+
+    private static class TimerRequestEventListener implements RequestEventListener {
+
+        private final ConcurrentMap<EventTypeAndMethod, Timer> timers;
+        private final Clock clock;
+        private final long start;
+        private Timer.Context resourceMethodStartContext;
+        private Timer.Context requestMatchedContext;
+        private Timer.Context responseFiltersStartContext;
+
+        public TimerRequestEventListener(final ConcurrentMap<EventTypeAndMethod, Timer> timers, final Clock clock) {
+            this.timers = timers;
+            this.clock = clock;
+            start = clock.getTick();
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            switch (event.getType()) {
+                case RESOURCE_METHOD_START:
+                    resourceMethodStartContext = context(event);
+                    break;
+                case REQUEST_MATCHED:
+                    requestMatchedContext = context(event);
+                    break;
+                case RESP_FILTERS_START:
+                    responseFiltersStartContext = context(event);
+                    break;
+                case RESOURCE_METHOD_FINISHED:
+                    if (resourceMethodStartContext != null) {
+                        resourceMethodStartContext.close();
+                    }
+                    break;
+                case REQUEST_FILTERED:
+                    if (requestMatchedContext != null) {
+                        requestMatchedContext.close();
+                    }
+                    break;
+                case RESP_FILTERS_FINISHED:
+                    if (responseFiltersStartContext != null) {
+                        responseFiltersStartContext.close();
+                    }
+                    break;
+                case FINISHED:
+                    if (requestMatchedContext != null && responseFiltersStartContext != null) {
+                        final Timer timer = timer(event);
+                        if (timer != null) {
+                            timer.update(clock.getTick() - start, TimeUnit.NANOSECONDS);
+                        }
+                    }
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        private Timer timer(RequestEvent event) {
+            final ResourceMethod resourceMethod = event.getUriInfo().getMatchedResourceMethod();
+            if (resourceMethod == null) {
+                return null;
+            }
+            return timers.get(new EventTypeAndMethod(event.getType(), resourceMethod.getInvocable().getDefinitionMethod()));
+        }
+
+        private Timer.Context context(RequestEvent event) {
+            final Timer timer = timer(event);
+            return timer != null ? timer.time() : null;
+        }
+    }
+
+    private static class MeterRequestEventListener implements RequestEventListener {
+        private final ConcurrentMap<Method, Meter> meters;
+
+        public MeterRequestEventListener(final ConcurrentMap<Method, Meter> meters) {
+            this.meters = meters;
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            if (event.getType() == RequestEvent.Type.RESOURCE_METHOD_START) {
+                final Meter meter = this.meters.get(event.getUriInfo().getMatchedResourceMethod().getInvocable().getDefinitionMethod());
+                if (meter != null) {
+                    meter.mark();
+                }
+            }
+        }
+    }
+
+    private static class ExceptionMeterRequestEventListener implements RequestEventListener {
+        private final ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters;
+
+        public ExceptionMeterRequestEventListener(final ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters) {
+            this.exceptionMeters = exceptionMeters;
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            if (event.getType() == RequestEvent.Type.ON_EXCEPTION) {
+                final ResourceMethod method = event.getUriInfo().getMatchedResourceMethod();
+                final ExceptionMeterMetric metric = (method != null) ?
+                        this.exceptionMeters.get(method.getInvocable().getDefinitionMethod()) : null;
+
+                if (metric != null) {
+                    if (metric.cause.isAssignableFrom(event.getException().getClass()) ||
+                            (event.getException().getCause() != null &&
+                                    metric.cause.isAssignableFrom(event.getException().getCause().getClass()))) {
+                        metric.meter.mark();
+                    }
+                }
+            }
+        }
+    }
+
+    private static class ResponseMeterRequestEventListener implements RequestEventListener {
+        private final ConcurrentMap<Method, ResponseMeterMetric> responseMeters;
+
+        public ResponseMeterRequestEventListener(final ConcurrentMap<Method, ResponseMeterMetric> responseMeters) {
+            this.responseMeters = responseMeters;
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            if (event.getType() == RequestEvent.Type.FINISHED) {
+                final ResourceMethod method = event.getUriInfo().getMatchedResourceMethod();
+                final ResponseMeterMetric metric = (method != null) ?
+                        this.responseMeters.get(method.getInvocable().getDefinitionMethod()) : null;
+
+                if (metric != null) {
+                    ContainerResponse containerResponse = event.getContainerResponse();
+                    if (containerResponse == null && event.getException() != null) {
+                        metric.mark(500);
+                    } else if (containerResponse != null) {
+                        metric.mark(containerResponse.getStatus());
+                    }
+                }
+            }
+        }
+    }
+
+    private static class ChainedRequestEventListener implements RequestEventListener {
+        private final RequestEventListener[] listeners;
+
+        private ChainedRequestEventListener(final RequestEventListener... listeners) {
+            this.listeners = listeners;
+        }
+
+        @Override
+        public void onEvent(final RequestEvent event) {
+            for (RequestEventListener listener : listeners) {
+                listener.onEvent(event);
+            }
+        }
+    }
+
+    @Override
+    public void onEvent(ApplicationEvent event) {
+        if (event.getType() == ApplicationEvent.Type.INITIALIZATION_APP_FINISHED) {
+            registerMetricsForModel(event.getResourceModel());
+        }
+    }
+
+    @Override
+    public ResourceModel processResourceModel(ResourceModel resourceModel, Configuration configuration) {
+        return resourceModel;
+    }
+
+    @Override
+    public ResourceModel processSubResource(ResourceModel subResourceModel, Configuration configuration) {
+        registerMetricsForModel(subResourceModel);
+        return subResourceModel;
+    }
+
+    private void registerMetricsForModel(ResourceModel resourceModel) {
+        for (final Resource resource : resourceModel.getResources()) {
+
+            final Timed classLevelTimed = getClassLevelAnnotation(resource, Timed.class);
+            final Metered classLevelMetered = getClassLevelAnnotation(resource, Metered.class);
+            final ExceptionMetered classLevelExceptionMetered = getClassLevelAnnotation(resource, ExceptionMetered.class);
+            final ResponseMetered classLevelResponseMetered = getClassLevelAnnotation(resource, ResponseMetered.class);
+
+            for (final ResourceMethod method : resource.getAllMethods()) {
+                registerTimedAnnotations(method, classLevelTimed);
+                registerMeteredAnnotations(method, classLevelMetered);
+                registerExceptionMeteredAnnotations(method, classLevelExceptionMetered);
+                registerResponseMeteredAnnotations(method, classLevelResponseMetered);
+            }
+
+            for (final Resource childResource : resource.getChildResources()) {
+
+                final Timed classLevelTimedChild = getClassLevelAnnotation(childResource, Timed.class);
+                final Metered classLevelMeteredChild = getClassLevelAnnotation(childResource, Metered.class);
+                final ExceptionMetered classLevelExceptionMeteredChild = getClassLevelAnnotation(childResource, ExceptionMetered.class);
+                final ResponseMetered classLevelResponseMeteredChild = getClassLevelAnnotation(childResource, ResponseMetered.class);
+
+                for (final ResourceMethod method : childResource.getAllMethods()) {
+                    registerTimedAnnotations(method, classLevelTimedChild);
+                    registerMeteredAnnotations(method, classLevelMeteredChild);
+                    registerExceptionMeteredAnnotations(method, classLevelExceptionMeteredChild);
+                    registerResponseMeteredAnnotations(method, classLevelResponseMeteredChild);
+                }
+            }
+        }
+    }
+
+    @Override
+    public RequestEventListener onRequest(final RequestEvent event) {
+        final RequestEventListener listener = new ChainedRequestEventListener(
+                new TimerRequestEventListener(timers, clock),
+                new MeterRequestEventListener(meters),
+                new ExceptionMeterRequestEventListener(exceptionMeters),
+                new ResponseMeterRequestEventListener(responseMeters));
+
+        return listener;
+    }
+
+    private <T extends Annotation> T getClassLevelAnnotation(final Resource resource, final Class<T> annotationClazz) {
+        T annotation = null;
+
+        for (final Class<?> clazz : resource.getHandlerClasses()) {
+            annotation = clazz.getAnnotation(annotationClazz);
+
+            if (annotation != null) {
+                break;
+            }
+        }
+        return annotation;
+    }
+
+    private void registerTimedAnnotations(final ResourceMethod method, final Timed classLevelTimed) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+        if (classLevelTimed != null) {
+            registerTimers(method, definitionMethod, classLevelTimed);
+            return;
+        }
+
+        final Timed annotation = definitionMethod.getAnnotation(Timed.class);
+        if (annotation != null) {
+            registerTimers(method, definitionMethod, annotation);
+        }
+    }
+
+    private void registerTimers(ResourceMethod method, Method definitionMethod, Timed annotation) {
+        timers.putIfAbsent(EventTypeAndMethod.requestMethodStart(definitionMethod), timerMetric(metrics, method, annotation));
+        if (trackFilters) {
+            timers.putIfAbsent(EventTypeAndMethod.requestMatched(definitionMethod), timerMetric(metrics, method, annotation, REQUEST_FILTERING));
+            timers.putIfAbsent(EventTypeAndMethod.respFiltersStart(definitionMethod), timerMetric(metrics, method, annotation, RESPONSE_FILTERING));
+            timers.putIfAbsent(EventTypeAndMethod.finished(definitionMethod), timerMetric(metrics, method, annotation, TOTAL));
+        }
+    }
+
+    private void registerMeteredAnnotations(final ResourceMethod method, final Metered classLevelMetered) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+
+        if (classLevelMetered != null) {
+            meters.putIfAbsent(definitionMethod, meterMetric(metrics, method, classLevelMetered));
+            return;
+        }
+        final Metered annotation = definitionMethod.getAnnotation(Metered.class);
+
+        if (annotation != null) {
+            meters.putIfAbsent(definitionMethod, meterMetric(metrics, method, annotation));
+        }
+    }
+
+    private void registerExceptionMeteredAnnotations(final ResourceMethod method, final ExceptionMetered classLevelExceptionMetered) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+
+        if (classLevelExceptionMetered != null) {
+            exceptionMeters.putIfAbsent(definitionMethod, new ExceptionMeterMetric(metrics, method, classLevelExceptionMetered));
+            return;
+        }
+        final ExceptionMetered annotation = definitionMethod.getAnnotation(ExceptionMetered.class);
+
+        if (annotation != null) {
+            exceptionMeters.putIfAbsent(definitionMethod, new ExceptionMeterMetric(metrics, method, annotation));
+        }
+    }
+
+    private void registerResponseMeteredAnnotations(final ResourceMethod method, final ResponseMetered classLevelResponseMetered) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+
+        if (classLevelResponseMetered != null) {
+            responseMeters.putIfAbsent(definitionMethod, new ResponseMeterMetric(metrics, method, classLevelResponseMetered));
+            return;
+        }
+        final ResponseMetered annotation = definitionMethod.getAnnotation(ResponseMetered.class);
+
+        if (annotation != null) {
+            responseMeters.putIfAbsent(definitionMethod, new ResponseMeterMetric(metrics, method, annotation));
+        }
+    }
+
+    private Timer timerMetric(final MetricRegistry registry,
+                              final ResourceMethod method,
+                              final Timed timed,
+                              final String... suffixes) {
+        final String name = chooseName(timed.name(), timed.absolute(), method, suffixes);
+        return registry.timer(name, () -> new Timer(reservoirSupplier.get(), clock));
+    }
+
+    private Meter meterMetric(final MetricRegistry registry,
+                              final ResourceMethod method,
+                              final Metered metered) {
+        final String name = chooseName(metered.name(), metered.absolute(), method);
+        return registry.meter(name, () -> new Meter(clock));
+    }
+
+    protected static String chooseName(final String explicitName, final boolean absolute, final ResourceMethod method,
+                                       final String... suffixes) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+        final String metricName;
+        if (explicitName != null && !explicitName.isEmpty()) {
+            metricName = absolute ? explicitName : name(definitionMethod.getDeclaringClass(), explicitName);
+        } else {
+            metricName = name(definitionMethod.getDeclaringClass(), definitionMethod.getName());
+        }
+        return name(metricName, suffixes);
+    }
+
+    private static class EventTypeAndMethod {
+
+        private final RequestEvent.Type type;
+        private final Method method;
+
+        private EventTypeAndMethod(RequestEvent.Type type, Method method) {
+            this.type = type;
+            this.method = method;
+        }
+
+        static EventTypeAndMethod requestMethodStart(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.RESOURCE_METHOD_START, method);
+        }
+
+        static EventTypeAndMethod requestMatched(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.REQUEST_MATCHED, method);
+        }
+
+        static EventTypeAndMethod respFiltersStart(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.RESP_FILTERS_START, method);
+        }
+
+        static EventTypeAndMethod finished(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.FINISHED, method);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+
+            EventTypeAndMethod that = (EventTypeAndMethod) o;
+
+            if (type != that.type) {
+                return false;
+            }
+            return method.equals(that.method);
+        }
+
+        @Override
+        public int hashCode() {
+            int result = type.hashCode();
+            result = 31 * result + method.hashCode();
+            return result;
+        }
+    }
+}
diff --git a/metrics-jersey3/src/main/java/com/codahale/metrics/jersey3/MetricsFeature.java b/metrics-jersey3/src/main/java/com/codahale/metrics/jersey3/MetricsFeature.java
new file mode 100644
index 0000000..7ed38b1
--- /dev/null
+++ b/metrics-jersey3/src/main/java/com/codahale/metrics/jersey3/MetricsFeature.java
@@ -0,0 +1,97 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Reservoir;
+import com.codahale.metrics.SharedMetricRegistries;
+import jakarta.ws.rs.core.Feature;
+import jakarta.ws.rs.core.FeatureContext;
+
+import java.util.function.Supplier;
+
+/**
+ * A {@link Feature} which registers a {@link InstrumentedResourceMethodApplicationListener}
+ * for recording request events.
+ */
+public class MetricsFeature implements Feature {
+
+    private final MetricRegistry registry;
+    private final Clock clock;
+    private final boolean trackFilters;
+    private final Supplier<Reservoir> reservoirSupplier;
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     */
+    public MetricsFeature(MetricRegistry registry) {
+        this(registry, Clock.defaultClock());
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public MetricsFeature(MetricRegistry registry, Supplier<Reservoir> reservoirSupplier) {
+        this(registry, Clock.defaultClock(), false, reservoirSupplier);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock) {
+        this(registry, clock, false);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock, boolean trackFilters) {
+        this(registry, clock, trackFilters, ExponentiallyDecayingReservoir::new);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock, boolean trackFilters, Supplier<Reservoir> reservoirSupplier) {
+        this.registry = registry;
+        this.clock = clock;
+        this.trackFilters = trackFilters;
+        this.reservoirSupplier = reservoirSupplier;
+    }
+
+    public MetricsFeature(String registryName) {
+        this(SharedMetricRegistries.getOrCreate(registryName));
+    }
+
+    /**
+     * A call-back method called when the feature is to be enabled in a given
+     * runtime configuration scope.
+     * <p>
+     * The responsibility of the feature is to properly update the supplied runtime configuration context
+     * and return {@code true} if the feature was successfully enabled or {@code false} otherwise.
+     * <p>
+     * Note that under some circumstances the feature may decide not to enable itself, which
+     * is indicated by returning {@code false}. In such case the configuration context does
+     * not add the feature to the collection of enabled features and a subsequent call to
+     * {@link jakarta.ws.rs.core.Configuration#isEnabled(jakarta.ws.rs.core.Feature)} or
+     * {@link jakarta.ws.rs.core.Configuration#isEnabled(Class)} method
+     * would return {@code false}.
+     * <p>
+     *
+     * @param context configurable context in which the feature should be enabled.
+     * @return {@code true} if the feature was successfully enabled, {@code false}
+     * otherwise.
+     */
+    @Override
+    public boolean configure(FeatureContext context) {
+        context.register(new InstrumentedResourceMethodApplicationListener(registry, clock, trackFilters, reservoirSupplier));
+        return true;
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/CustomReservoirImplementationTest.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/CustomReservoirImplementationTest.java
new file mode 100644
index 0000000..4999ee5
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/CustomReservoirImplementationTest.java
@@ -0,0 +1,44 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.UniformReservoir;
+import com.codahale.metrics.jersey3.resources.InstrumentedResourceTimedPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class CustomReservoirImplementationTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        return new ResourceConfig()
+                .register(new MetricsFeature(this.registry, UniformReservoir::new))
+                .register(InstrumentedResourceTimedPerClass.class);
+    }
+
+    @Test
+    public void timerHistogramIsUsingCustomReservoirImplementation() {
+        assertThat(target("timedPerClass").request().get(String.class)).isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedResourceTimedPerClass.class, "timedPerClass"));
+        assertThat(timer)
+                .extracting("histogram")
+                .extracting("reservoir")
+                .isInstanceOf(UniformReservoir.class);
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonFilterMetricsJerseyTest.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonFilterMetricsJerseyTest.java
new file mode 100644
index 0000000..aa5e2f1
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonFilterMetricsJerseyTest.java
@@ -0,0 +1,162 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.jersey3.resources.InstrumentedFilteredResource;
+import com.codahale.metrics.jersey3.resources.TestRequestFilter;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig} with filter tracking
+ */
+public class SingletonFilterMetricsJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    private TestClock testClock;
+
+    @Override
+    protected Application configure() {
+        registry = new MetricRegistry();
+        testClock = new TestClock();
+        ResourceConfig config = new ResourceConfig();
+        config = config.register(new MetricsFeature(this.registry, testClock, true));
+        config = config.register(new TestRequestFilter(testClock));
+        config = config.register(new InstrumentedFilteredResource(testClock));
+        return config;
+    }
+
+    @Before
+    public void resetClock() {
+        testClock.tick = 0;
+    }
+
+    @Test
+    public void timedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void explicitNamesAreTimed() {
+        assertThat(target("named")
+                .request()
+                .get(String.class))
+                .isEqualTo("fancy");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "fancyName"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void absoluteNamesAreTimed() {
+        assertThat(target("absolute")
+                .request()
+                .get(String.class))
+                .isEqualTo("absolute");
+
+        final Timer timer = registry.timer("absolutelyFancy");
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void requestFiltersOfTimedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "request", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void responseFiltersOfTimedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "response", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void totalTimeOfTimedMethodsIsTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "total"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(5);
+    }
+
+    @Test
+    public void requestFiltersOfNamedMethodsAreTimed() {
+        assertThat(target("named")
+                .request()
+                .get(String.class))
+                .isEqualTo("fancy");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "fancyName", "request", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void requestFiltersOfAbsoluteMethodsAreTimed() {
+        assertThat(target("absolute")
+                .request()
+                .get(String.class))
+                .isEqualTo("absolute");
+
+        final Timer timer = registry.timer(name("absolutelyFancy", "request", "filtering"));
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void subResourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.InstrumentedFilteredSubResource.class,
+                "timed"));
+        assertThat(timer.getCount()).isEqualTo(1);
+
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsExceptionMeteredPerClassJerseyTest.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsExceptionMeteredPerClassJerseyTest.java
new file mode 100644
index 0000000..d20387f
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsExceptionMeteredPerClassJerseyTest.java
@@ -0,0 +1,98 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.jersey3.resources.InstrumentedResourceExceptionMeteredPerClass;
+import com.codahale.metrics.jersey3.resources.InstrumentedSubResourceExceptionMeteredPerClass;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsExceptionMeteredPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceExceptionMeteredPerClass.class);
+
+        return config;
+    }
+
+    @Test
+    public void exceptionMeteredMethodsAreExceptionMetered() {
+        final Meter meter = registry.meter(name(InstrumentedResourceExceptionMeteredPerClass.class,
+                "exceptionMetered",
+                "exceptions"));
+
+        assertThat(target("exception-metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("fuh");
+
+        assertThat(meter.getCount()).isZero();
+
+        try {
+            target("exception-metered")
+                    .queryParam("splode", true)
+                    .request()
+                    .get(String.class);
+
+            failBecauseExceptionWasNotThrown(ProcessingException.class);
+        } catch (ProcessingException e) {
+            assertThat(e.getCause()).isInstanceOf(IOException.class);
+        }
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        final Meter meter = registry.meter(name(InstrumentedSubResourceExceptionMeteredPerClass.class,
+                "exceptionMetered",
+                "exceptions"));
+
+        assertThat(target("subresource/exception-metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("fuh");
+
+        assertThat(meter.getCount()).isZero();
+
+        try {
+            target("subresource/exception-metered")
+                    .queryParam("splode", true)
+                    .request()
+                    .get(String.class);
+
+            failBecauseExceptionWasNotThrown(ProcessingException.class);
+        } catch (ProcessingException e) {
+            assertThat(e.getCause()).isInstanceOf(IOException.class);
+        }
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsJerseyTest.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsJerseyTest.java
new file mode 100644
index 0000000..bb5afd3
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsJerseyTest.java
@@ -0,0 +1,171 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.jersey3.resources.InstrumentedResource;
+import com.codahale.metrics.jersey3.resources.InstrumentedSubResource;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Application;
+import jakarta.ws.rs.core.Response;
+import org.glassfish.jersey.client.ClientResponse;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link org.glassfish.jersey.server.ResourceConfig}
+ */
+public class SingletonMetricsJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResource.class);
+
+        return config;
+    }
+
+    @Test
+    public void timedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedResource.class, "timed"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void meteredMethodsAreMetered() {
+        assertThat(target("metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("woo");
+
+        final Meter meter = registry.meter(name(InstrumentedResource.class, "metered"));
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void exceptionMeteredMethodsAreExceptionMetered() {
+        final Meter meter = registry.meter(name(InstrumentedResource.class,
+                "exceptionMetered",
+                "exceptions"));
+
+        assertThat(target("exception-metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("fuh");
+
+        assertThat(meter.getCount()).isZero();
+
+        try {
+            target("exception-metered")
+                    .queryParam("splode", true)
+                    .request()
+                    .get(String.class);
+
+            failBecauseExceptionWasNotThrown(ProcessingException.class);
+        } catch (ProcessingException e) {
+            assertThat(e.getCause()).isInstanceOf(IOException.class);
+        }
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredMethodsAreMeteredWithDetailedLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "200-responses"));
+        final Meter meter201 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "201-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(meter201.getCount()).isZero();
+        assertThat(target("response-metered-detailed")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+        assertThat(target("response-metered-detailed")
+                .queryParam("status_code", 201)
+                .request()
+                .get().getStatus())
+                .isEqualTo(201);
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isOne();
+        assertThat(meter201.getCount()).isOne();
+    }
+
+    @Test
+    public void responseMeteredMethodsAreMeteredWithAllLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredAll",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredAll",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("response-metered-all")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isOne();
+    }
+
+    @Test
+    public void testResourceNotFound() {
+        final Response response = target().path("not-found").request().get();
+        assertThat(response.getStatus()).isEqualTo(404);
+
+        try {
+            target().path("not-found").request().get(ClientResponse.class);
+            failBecauseExceptionWasNotThrown(NotFoundException.class);
+        } catch (NotFoundException e) {
+            assertThat(e.getMessage()).isEqualTo("HTTP 404 Not Found");
+        }
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedSubResource.class, "timed"));
+        assertThat(timer.getCount()).isEqualTo(1);
+
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsMeteredPerClassJerseyTest.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsMeteredPerClassJerseyTest.java
new file mode 100644
index 0000000..0e71640
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsMeteredPerClassJerseyTest.java
@@ -0,0 +1,66 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.jersey3.resources.InstrumentedResourceMeteredPerClass;
+import com.codahale.metrics.jersey3.resources.InstrumentedSubResourceMeteredPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsMeteredPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceMeteredPerClass.class);
+
+        return config;
+    }
+
+    @Test
+    public void meteredPerClassMethodsAreMetered() {
+        assertThat(target("meteredPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Meter meter = registry.meter(name(InstrumentedResourceMeteredPerClass.class, "meteredPerClass"));
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/meteredPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Meter meter = registry.meter(name(InstrumentedSubResourceMeteredPerClass.class, "meteredPerClass"));
+        assertThat(meter.getCount()).isEqualTo(1);
+
+    }
+
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsResponseMeteredPerClassJerseyTest.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsResponseMeteredPerClassJerseyTest.java
new file mode 100644
index 0000000..f85b698
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsResponseMeteredPerClassJerseyTest.java
@@ -0,0 +1,146 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.jersey3.exception.mapper.TestExceptionMapper;
+import com.codahale.metrics.jersey3.resources.InstrumentedResourceResponseMeteredPerClass;
+import com.codahale.metrics.jersey3.resources.InstrumentedSubResourceResponseMeteredPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsResponseMeteredPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceResponseMeteredPerClass.class);
+        config = config.register(new TestExceptionMapper());
+
+        return config;
+    }
+
+    @Test
+    public void responseMetered2xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered2xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        final Meter meter2xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered2xxPerClass",
+                "2xx-responses"));
+
+        assertThat(meter2xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMetered4xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered4xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(400);
+        assertThat(target("responseMeteredBadRequestPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(400);
+
+        final Meter meter4xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered4xxPerClass",
+                "4xx-responses"));
+        final Meter meterException4xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredBadRequestPerClass",
+                "4xx-responses"));
+
+        assertThat(meter4xx.getCount()).isEqualTo(1);
+        assertThat(meterException4xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMetered5xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered5xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(500);
+
+        final Meter meter5xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered5xxPerClass",
+                "5xx-responses"));
+
+        assertThat(meter5xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredMappedExceptionPerClassMethodsAreMetered() {
+        assertThat(target("responseMeteredTestExceptionPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(500);
+
+        final Meter meterTestException = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredTestExceptionPerClass",
+                "5xx-responses"));
+
+        assertThat(meterTestException.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredUnmappedExceptionPerClassMethodsAreMetered() {
+        try {
+            target("responseMeteredRuntimeExceptionPerClass")
+                    .request()
+                    .get();
+            fail("expected RuntimeException");
+        } catch (Exception e) {
+            assertThat(e.getCause()).isInstanceOf(RuntimeException.class);
+        }
+
+        final Meter meterException5xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredRuntimeExceptionPerClass",
+                "5xx-responses"));
+
+        assertThat(meterException5xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        final Meter meter2xx = registry.meter(name(InstrumentedSubResourceResponseMeteredPerClass.class,
+                "responseMeteredPerClass",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedSubResourceResponseMeteredPerClass.class,
+                "responseMeteredPerClass",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("subresource/responseMeteredPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isOne();
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsTimedPerClassJerseyTest.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsTimedPerClassJerseyTest.java
new file mode 100644
index 0000000..0249e15
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/SingletonMetricsTimedPerClassJerseyTest.java
@@ -0,0 +1,66 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.jersey3.resources.InstrumentedResourceTimedPerClass;
+import com.codahale.metrics.jersey3.resources.InstrumentedSubResourceTimedPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsTimedPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceTimedPerClass.class);
+
+        return config;
+    }
+
+    @Test
+    public void timedPerClassMethodsAreTimed() {
+        assertThat(target("timedPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedResourceTimedPerClass.class, "timedPerClass"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/timedPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedSubResourceTimedPerClass.class, "timedPerClass"));
+        assertThat(timer.getCount()).isEqualTo(1);
+
+    }
+
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/TestClock.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/TestClock.java
new file mode 100644
index 0000000..1e6f5a2
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/TestClock.java
@@ -0,0 +1,13 @@
+package com.codahale.metrics.jersey3;
+
+import com.codahale.metrics.Clock;
+
+public class TestClock extends Clock {
+
+    public long tick;
+
+    @Override
+    public long getTick() {
+        return tick;
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/exception/TestException.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/exception/TestException.java
new file mode 100644
index 0000000..49beb0d
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/exception/TestException.java
@@ -0,0 +1,9 @@
+package com.codahale.metrics.jersey3.exception;
+
+public class TestException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    public TestException(String message) {
+        super(message);
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/exception/mapper/TestExceptionMapper.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/exception/mapper/TestExceptionMapper.java
new file mode 100644
index 0000000..45c2984
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/exception/mapper/TestExceptionMapper.java
@@ -0,0 +1,14 @@
+package com.codahale.metrics.jersey3.exception.mapper;
+
+import com.codahale.metrics.jersey3.exception.TestException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+
+@Provider
+public class TestExceptionMapper implements ExceptionMapper<TestException> {
+    @Override
+    public Response toResponse(TestException exception) {
+        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedFilteredResource.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedFilteredResource.java
new file mode 100644
index 0000000..ac379a7
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedFilteredResource.java
@@ -0,0 +1,61 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import com.codahale.metrics.jersey3.TestClock;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedFilteredResource {
+
+    private final TestClock testClock;
+
+    public InstrumentedFilteredResource(TestClock testClock) {
+        this.testClock = testClock;
+    }
+
+    @GET
+    @Timed
+    @Path("/timed")
+    public String timed() {
+        testClock.tick++;
+        return "yay";
+    }
+
+    @GET
+    @Timed(name = "fancyName")
+    @Path("/named")
+    public String named() {
+        testClock.tick++;
+        return "fancy";
+    }
+
+    @GET
+    @Timed(name = "absolutelyFancy", absolute = true)
+    @Path("/absolute")
+    public String absolute() {
+        testClock.tick++;
+        return "absolute";
+    }
+
+    @Path("/subresource")
+    public InstrumentedFilteredSubResource locateSubResource() {
+        return new InstrumentedFilteredSubResource();
+    }
+
+    @Produces(MediaType.TEXT_PLAIN)
+    public class InstrumentedFilteredSubResource {
+
+        @GET
+        @Timed
+        @Path("/timed")
+        public String timed() {
+            testClock.tick += 2;
+            return "yay";
+        }
+
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResource.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResource.java
new file mode 100644
index 0000000..ca0437f
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResource.java
@@ -0,0 +1,72 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.ExceptionMetered;
+import com.codahale.metrics.annotation.Metered;
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.io.IOException;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResource {
+    @GET
+    @Timed
+    @Path("/timed")
+    public String timed() {
+        return "yay";
+    }
+
+    @GET
+    @Metered
+    @Path("/metered")
+    public String metered() {
+        return "woo";
+    }
+
+    @GET
+    @ExceptionMetered(cause = IOException.class)
+    @Path("/exception-metered")
+    public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
+        if (splode) {
+            throw new IOException("AUGH");
+        }
+        return "fuh";
+    }
+
+    @GET
+    @ResponseMetered(level = DETAILED)
+    @Path("/response-metered-detailed")
+    public Response responseMeteredDetailed(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @GET
+    @ResponseMetered(level = COARSE)
+    @Path("/response-metered-coarse")
+    public Response responseMeteredCoarse(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @GET
+    @ResponseMetered(level = ALL)
+    @Path("/response-metered-all")
+    public Response responseMeteredAll(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResource locateSubResource() {
+        return new InstrumentedSubResource();
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceExceptionMeteredPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceExceptionMeteredPerClass.java
new file mode 100644
index 0000000..b60c5ba
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceExceptionMeteredPerClass.java
@@ -0,0 +1,32 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.ExceptionMetered;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+
+import java.io.IOException;
+
+@ExceptionMetered(cause = IOException.class)
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceExceptionMeteredPerClass {
+
+    @GET
+    @Path("/exception-metered")
+    public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
+        if (splode) {
+            throw new IOException("AUGH");
+        }
+        return "fuh";
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceExceptionMeteredPerClass locateSubResource() {
+        return new InstrumentedSubResourceExceptionMeteredPerClass();
+    }
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceMeteredPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceMeteredPerClass.java
new file mode 100644
index 0000000..232ec31
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceMeteredPerClass.java
@@ -0,0 +1,25 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.Metered;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Metered
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceMeteredPerClass {
+
+    @GET
+    @Path("/meteredPerClass")
+    public String meteredPerClass() {
+        return "yay";
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceMeteredPerClass locateSubResource() {
+        return new InstrumentedSubResourceMeteredPerClass();
+    }
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceResponseMeteredPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceResponseMeteredPerClass.java
new file mode 100644
index 0000000..062541d
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceResponseMeteredPerClass.java
@@ -0,0 +1,58 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.jersey3.exception.TestException;
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+@ResponseMetered
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceResponseMeteredPerClass {
+
+    @GET
+    @Path("/responseMetered2xxPerClass")
+    public Response responseMetered2xxPerClass() {
+        return Response.ok().build();
+    }
+
+    @GET
+    @Path("/responseMetered4xxPerClass")
+    public Response responseMetered4xxPerClass() {
+        return Response.status(Response.Status.BAD_REQUEST).build();
+    }
+
+    @GET
+    @Path("/responseMetered5xxPerClass")
+    public Response responseMetered5xxPerClass() {
+        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+    }
+
+    @GET
+    @Path("/responseMeteredBadRequestPerClass")
+    public String responseMeteredBadRequestPerClass() {
+        throw new BadRequestException();
+    }
+
+    @GET
+    @Path("/responseMeteredRuntimeExceptionPerClass")
+    public String responseMeteredRuntimeExceptionPerClass() {
+        throw new RuntimeException();
+    }
+
+    @GET
+    @Path("/responseMeteredTestExceptionPerClass")
+    public String responseMeteredTestExceptionPerClass() {
+        throw new TestException("test");
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceResponseMeteredPerClass locateSubResource() {
+        return new InstrumentedSubResourceResponseMeteredPerClass();
+    }
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceTimedPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceTimedPerClass.java
new file mode 100644
index 0000000..1d91d21
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedResourceTimedPerClass.java
@@ -0,0 +1,25 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Timed
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceTimedPerClass {
+
+    @GET
+    @Path("/timedPerClass")
+    public String timedPerClass() {
+        return "yay";
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceTimedPerClass locateSubResource() {
+        return new InstrumentedSubResourceTimedPerClass();
+    }
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResource.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResource.java
new file mode 100644
index 0000000..b1a3592
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResource.java
@@ -0,0 +1,19 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResource {
+
+    @GET
+    @Timed
+    @Path("/timed")
+    public String timed() {
+        return "yay";
+    }
+
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceExceptionMeteredPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceExceptionMeteredPerClass.java
new file mode 100644
index 0000000..4a1c5b2
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceExceptionMeteredPerClass.java
@@ -0,0 +1,24 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.ExceptionMetered;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+
+import java.io.IOException;
+
+@ExceptionMetered(cause = IOException.class)
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceExceptionMeteredPerClass {
+    @GET
+    @Path("/exception-metered")
+    public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
+        if (splode) {
+            throw new IOException("AUGH");
+        }
+        return "fuh";
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceMeteredPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceMeteredPerClass.java
new file mode 100644
index 0000000..5db617b
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceMeteredPerClass.java
@@ -0,0 +1,17 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.Metered;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Metered
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceMeteredPerClass {
+    @GET
+    @Path("/meteredPerClass")
+    public String meteredPerClass() {
+        return "yay";
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceResponseMeteredPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceResponseMeteredPerClass.java
new file mode 100644
index 0000000..3e74426
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceResponseMeteredPerClass.java
@@ -0,0 +1,20 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+
+@ResponseMetered(level = ALL)
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceResponseMeteredPerClass {
+    @GET
+    @Path("/responseMeteredPerClass")
+    public Response responseMeteredPerClass() {
+        return Response.status(Response.Status.OK).build();
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceTimedPerClass.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceTimedPerClass.java
new file mode 100644
index 0000000..538b9f9
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/InstrumentedSubResourceTimedPerClass.java
@@ -0,0 +1,17 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Timed
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceTimedPerClass {
+    @GET
+    @Path("/timedPerClass")
+    public String timedPerClass() {
+        return "yay";
+    }
+}
diff --git a/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/TestRequestFilter.java b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/TestRequestFilter.java
new file mode 100644
index 0000000..df6787c
--- /dev/null
+++ b/metrics-jersey3/src/test/java/com/codahale/metrics/jersey3/resources/TestRequestFilter.java
@@ -0,0 +1,21 @@
+package com.codahale.metrics.jersey3.resources;
+
+import com.codahale.metrics.jersey3.TestClock;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+
+import java.io.IOException;
+
+public class TestRequestFilter implements ContainerRequestFilter {
+
+    private final TestClock testClock;
+
+    public TestRequestFilter(TestClock testClock) {
+        this.testClock = testClock;
+    }
+
+    @Override
+    public void filter(ContainerRequestContext containerRequestContext) throws IOException {
+        testClock.tick += 4;
+    }
+}
diff --git a/metrics-jersey31/pom.xml b/metrics-jersey31/pom.xml
new file mode 100644
index 0000000..83ed0e0
--- /dev/null
+++ b/metrics-jersey31/pom.xml
@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jersey31</artifactId>
+    <name>Metrics Integration for Jersey 3.1.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of class providing Metrics integration for Jersey 3.1.x, the reference JAX-RS
+        implementation.
+    </description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.jersey31</javaModuleName>
+        <jersey.version>3.1.5</jersey.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.glassfish.jersey</groupId>
+                <artifactId>jersey-bom</artifactId>
+                <version>${jersey.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>jakarta.annotation</groupId>
+                <artifactId>jakarta.annotation-api</artifactId>
+                <version>2.1.1</version>
+            </dependency>
+            <dependency>
+                <groupId>jakarta.inject</groupId>
+                <artifactId>jakarta.inject-api</artifactId>
+                <version>2.0.1</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.core</groupId>
+            <artifactId>jersey-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.ws.rs</groupId>
+            <artifactId>jakarta.ws.rs-api</artifactId>
+            <version>3.1.0</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.13.1</version>
+            <scope>test</scope>
+         </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.inject</groupId>
+            <artifactId>jersey-hk2</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.core</groupId>
+            <artifactId>jersey-client</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.test-framework.providers</groupId>
+            <artifactId>jersey-test-framework-provider-inmemory</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.glassfish.jersey.test-framework</groupId>
+            <artifactId>jersey-test-framework-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jersey31/src/main/java/io/dropwizard/metrics/jersey31/InstrumentedResourceMethodApplicationListener.java b/metrics-jersey31/src/main/java/io/dropwizard/metrics/jersey31/InstrumentedResourceMethodApplicationListener.java
new file mode 100644
index 0000000..eeaf808
--- /dev/null
+++ b/metrics-jersey31/src/main/java/io/dropwizard/metrics/jersey31/InstrumentedResourceMethodApplicationListener.java
@@ -0,0 +1,549 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Reservoir;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ExceptionMetered;
+import com.codahale.metrics.annotation.Metered;
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.core.Configuration;
+import jakarta.ws.rs.ext.Provider;
+import org.glassfish.jersey.server.ContainerResponse;
+import org.glassfish.jersey.server.model.ModelProcessor;
+import org.glassfish.jersey.server.model.Resource;
+import org.glassfish.jersey.server.model.ResourceMethod;
+import org.glassfish.jersey.server.model.ResourceModel;
+import org.glassfish.jersey.server.monitoring.ApplicationEvent;
+import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
+import org.glassfish.jersey.server.monitoring.RequestEvent;
+import org.glassfish.jersey.server.monitoring.RequestEventListener;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+/**
+ * An application event listener that listens for Jersey application initialization to
+ * be finished, then creates a map of resource method that have metrics annotations.
+ * <p>
+ * Finally, it listens for method start events, and returns a {@link RequestEventListener}
+ * that updates the relevant metric for suitably annotated methods when it gets the
+ * request events indicating that the method is about to be invoked, or just got done
+ * being invoked.
+ */
+@Provider
+public class InstrumentedResourceMethodApplicationListener implements ApplicationEventListener, ModelProcessor {
+
+    private static final String[] REQUEST_FILTERING = {"request", "filtering"};
+    private static final String[] RESPONSE_FILTERING = {"response", "filtering"};
+    private static final String TOTAL = "total";
+
+    private final MetricRegistry metrics;
+    private final ConcurrentMap<EventTypeAndMethod, Timer> timers = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, Meter> meters = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Method, ResponseMeterMetric> responseMeters = new ConcurrentHashMap<>();
+
+    private final Clock clock;
+    private final boolean trackFilters;
+    private final Supplier<Reservoir> reservoirSupplier;
+
+    /**
+     * Construct an application event listener using the given metrics registry.
+     * <p>
+     * When using this constructor, the {@link InstrumentedResourceMethodApplicationListener}
+     * should be added to a Jersey {@code ResourceConfig} as a singleton.
+     *
+     * @param metrics a {@link MetricRegistry}
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics) {
+        this(metrics, Clock.defaultClock(), false);
+    }
+
+    /**
+     * Constructs a custom application listener.
+     *
+     * @param metrics      the metrics registry where the metrics will be stored
+     * @param clock        the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters whether the processing time for request and response filters should be tracked
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics, final Clock clock,
+                                                         final boolean trackFilters) {
+        this(metrics, clock, trackFilters, ExponentiallyDecayingReservoir::new);
+    }
+
+    /**
+     * Constructs a custom application listener.
+     *
+     * @param metrics           the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public InstrumentedResourceMethodApplicationListener(final MetricRegistry metrics, final Clock clock,
+                                                         final boolean trackFilters,
+                                                         final Supplier<Reservoir> reservoirSupplier) {
+        this.metrics = metrics;
+        this.clock = clock;
+        this.trackFilters = trackFilters;
+        this.reservoirSupplier = reservoirSupplier;
+    }
+
+    /**
+     * A private class to maintain the metric for a method annotated with the
+     * {@link ExceptionMetered} annotation, which needs to maintain both a meter
+     * and a cause for which the meter should be updated.
+     */
+    private static class ExceptionMeterMetric {
+        public final Meter meter;
+        public final Class<? extends Throwable> cause;
+
+        public ExceptionMeterMetric(final MetricRegistry registry,
+                                    final ResourceMethod method,
+                                    final ExceptionMetered exceptionMetered) {
+            final String name = chooseName(exceptionMetered.name(),
+                    exceptionMetered.absolute(), method, ExceptionMetered.DEFAULT_NAME_SUFFIX);
+            this.meter = registry.meter(name);
+            this.cause = exceptionMetered.cause();
+        }
+    }
+
+    /**
+     * A private class to maintain the metrics for a method annotated with the
+     * {@link ResponseMetered} annotation, which needs to maintain meters for
+     * different response codes
+     */
+    private static class ResponseMeterMetric {
+        private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+        private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+        private final List<Meter> meters;
+        private final Map<Integer, Meter> responseCodeMeters;
+        private final MetricRegistry metricRegistry;
+        private final String metricName;
+        private final ResponseMeteredLevel level;
+
+        public ResponseMeterMetric(final MetricRegistry registry,
+                                   final ResourceMethod method,
+                                   final ResponseMetered responseMetered) {
+            this.metricName = chooseName(responseMetered.name(), responseMetered.absolute(), method);
+            this.level = responseMetered.level();
+            this.meters = COARSE_METER_LEVELS.contains(level) ?
+                    Collections.unmodifiableList(Arrays.asList(
+                            registry.meter(name(metricName, "1xx-responses")), // 1xx
+                            registry.meter(name(metricName, "2xx-responses")), // 2xx
+                            registry.meter(name(metricName, "3xx-responses")), // 3xx
+                            registry.meter(name(metricName, "4xx-responses")), // 4xx
+                            registry.meter(name(metricName, "5xx-responses"))  // 5xx
+                    )) : Collections.emptyList();
+            this.responseCodeMeters = DETAILED_METER_LEVELS.contains(level) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+            this.metricRegistry = registry;
+        }
+
+        public void mark(int statusCode) {
+            if (DETAILED_METER_LEVELS.contains(level)) {
+                getResponseCodeMeter(statusCode).mark();
+            }
+
+            if (COARSE_METER_LEVELS.contains(level)) {
+                final int responseStatus = statusCode / 100;
+                if (responseStatus >= 1 && responseStatus <= 5) {
+                    meters.get(responseStatus - 1).mark();
+                }
+            }
+        }
+
+        private Meter getResponseCodeMeter(int statusCode) {
+            return responseCodeMeters
+                    .computeIfAbsent(statusCode, sc -> metricRegistry
+                            .meter(name(metricName, String.format("%d-responses", sc))));
+        }
+    }
+
+    private static class TimerRequestEventListener implements RequestEventListener {
+
+        private final ConcurrentMap<EventTypeAndMethod, Timer> timers;
+        private final Clock clock;
+        private final long start;
+        private Timer.Context resourceMethodStartContext;
+        private Timer.Context requestMatchedContext;
+        private Timer.Context responseFiltersStartContext;
+
+        public TimerRequestEventListener(final ConcurrentMap<EventTypeAndMethod, Timer> timers, final Clock clock) {
+            this.timers = timers;
+            this.clock = clock;
+            start = clock.getTick();
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            switch (event.getType()) {
+                case RESOURCE_METHOD_START:
+                    resourceMethodStartContext = context(event);
+                    break;
+                case REQUEST_MATCHED:
+                    requestMatchedContext = context(event);
+                    break;
+                case RESP_FILTERS_START:
+                    responseFiltersStartContext = context(event);
+                    break;
+                case RESOURCE_METHOD_FINISHED:
+                    if (resourceMethodStartContext != null) {
+                        resourceMethodStartContext.close();
+                    }
+                    break;
+                case REQUEST_FILTERED:
+                    if (requestMatchedContext != null) {
+                        requestMatchedContext.close();
+                    }
+                    break;
+                case RESP_FILTERS_FINISHED:
+                    if (responseFiltersStartContext != null) {
+                        responseFiltersStartContext.close();
+                    }
+                    break;
+                case FINISHED:
+                    if (requestMatchedContext != null && responseFiltersStartContext != null) {
+                        final Timer timer = timer(event);
+                        if (timer != null) {
+                            timer.update(clock.getTick() - start, TimeUnit.NANOSECONDS);
+                        }
+                    }
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        private Timer timer(RequestEvent event) {
+            final ResourceMethod resourceMethod = event.getUriInfo().getMatchedResourceMethod();
+            if (resourceMethod == null) {
+                return null;
+            }
+            return timers.get(new EventTypeAndMethod(event.getType(), resourceMethod.getInvocable().getDefinitionMethod()));
+        }
+
+        private Timer.Context context(RequestEvent event) {
+            final Timer timer = timer(event);
+            return timer != null ? timer.time() : null;
+        }
+    }
+
+    private static class MeterRequestEventListener implements RequestEventListener {
+        private final ConcurrentMap<Method, Meter> meters;
+
+        public MeterRequestEventListener(final ConcurrentMap<Method, Meter> meters) {
+            this.meters = meters;
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            if (event.getType() == RequestEvent.Type.RESOURCE_METHOD_START) {
+                final Meter meter = this.meters.get(event.getUriInfo().getMatchedResourceMethod().getInvocable().getDefinitionMethod());
+                if (meter != null) {
+                    meter.mark();
+                }
+            }
+        }
+    }
+
+    private static class ExceptionMeterRequestEventListener implements RequestEventListener {
+        private final ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters;
+
+        public ExceptionMeterRequestEventListener(final ConcurrentMap<Method, ExceptionMeterMetric> exceptionMeters) {
+            this.exceptionMeters = exceptionMeters;
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            if (event.getType() == RequestEvent.Type.ON_EXCEPTION) {
+                final ResourceMethod method = event.getUriInfo().getMatchedResourceMethod();
+                final ExceptionMeterMetric metric = (method != null) ?
+                        this.exceptionMeters.get(method.getInvocable().getDefinitionMethod()) : null;
+
+                if (metric != null) {
+                    if (metric.cause.isAssignableFrom(event.getException().getClass()) ||
+                            (event.getException().getCause() != null &&
+                                    metric.cause.isAssignableFrom(event.getException().getCause().getClass()))) {
+                        metric.meter.mark();
+                    }
+                }
+            }
+        }
+    }
+
+    private static class ResponseMeterRequestEventListener implements RequestEventListener {
+        private final ConcurrentMap<Method, ResponseMeterMetric> responseMeters;
+
+        public ResponseMeterRequestEventListener(final ConcurrentMap<Method, ResponseMeterMetric> responseMeters) {
+            this.responseMeters = responseMeters;
+        }
+
+        @Override
+        public void onEvent(RequestEvent event) {
+            if (event.getType() == RequestEvent.Type.FINISHED) {
+                final ResourceMethod method = event.getUriInfo().getMatchedResourceMethod();
+                final ResponseMeterMetric metric = (method != null) ?
+                        this.responseMeters.get(method.getInvocable().getDefinitionMethod()) : null;
+
+                if (metric != null) {
+                    ContainerResponse containerResponse = event.getContainerResponse();
+                    if (containerResponse == null && event.getException() != null) {
+                        metric.mark(500);
+                    } else if (containerResponse != null) {
+                        metric.mark(containerResponse.getStatus());
+                    }
+                }
+            }
+        }
+    }
+
+    private static class ChainedRequestEventListener implements RequestEventListener {
+        private final RequestEventListener[] listeners;
+
+        private ChainedRequestEventListener(final RequestEventListener... listeners) {
+            this.listeners = listeners;
+        }
+
+        @Override
+        public void onEvent(final RequestEvent event) {
+            for (RequestEventListener listener : listeners) {
+                listener.onEvent(event);
+            }
+        }
+    }
+
+    @Override
+    public void onEvent(ApplicationEvent event) {
+        if (event.getType() == ApplicationEvent.Type.INITIALIZATION_APP_FINISHED) {
+            registerMetricsForModel(event.getResourceModel());
+        }
+    }
+
+    @Override
+    public ResourceModel processResourceModel(ResourceModel resourceModel, Configuration configuration) {
+        return resourceModel;
+    }
+
+    @Override
+    public ResourceModel processSubResource(ResourceModel subResourceModel, Configuration configuration) {
+        registerMetricsForModel(subResourceModel);
+        return subResourceModel;
+    }
+
+    private void registerMetricsForModel(ResourceModel resourceModel) {
+        for (final Resource resource : resourceModel.getResources()) {
+
+            final Timed classLevelTimed = getClassLevelAnnotation(resource, Timed.class);
+            final Metered classLevelMetered = getClassLevelAnnotation(resource, Metered.class);
+            final ExceptionMetered classLevelExceptionMetered = getClassLevelAnnotation(resource, ExceptionMetered.class);
+            final ResponseMetered classLevelResponseMetered = getClassLevelAnnotation(resource, ResponseMetered.class);
+
+            for (final ResourceMethod method : resource.getAllMethods()) {
+                registerTimedAnnotations(method, classLevelTimed);
+                registerMeteredAnnotations(method, classLevelMetered);
+                registerExceptionMeteredAnnotations(method, classLevelExceptionMetered);
+                registerResponseMeteredAnnotations(method, classLevelResponseMetered);
+            }
+
+            for (final Resource childResource : resource.getChildResources()) {
+
+                final Timed classLevelTimedChild = getClassLevelAnnotation(childResource, Timed.class);
+                final Metered classLevelMeteredChild = getClassLevelAnnotation(childResource, Metered.class);
+                final ExceptionMetered classLevelExceptionMeteredChild = getClassLevelAnnotation(childResource, ExceptionMetered.class);
+                final ResponseMetered classLevelResponseMeteredChild = getClassLevelAnnotation(childResource, ResponseMetered.class);
+
+                for (final ResourceMethod method : childResource.getAllMethods()) {
+                    registerTimedAnnotations(method, classLevelTimedChild);
+                    registerMeteredAnnotations(method, classLevelMeteredChild);
+                    registerExceptionMeteredAnnotations(method, classLevelExceptionMeteredChild);
+                    registerResponseMeteredAnnotations(method, classLevelResponseMeteredChild);
+                }
+            }
+        }
+    }
+
+    @Override
+    public RequestEventListener onRequest(final RequestEvent event) {
+        final RequestEventListener listener = new ChainedRequestEventListener(
+                new TimerRequestEventListener(timers, clock),
+                new MeterRequestEventListener(meters),
+                new ExceptionMeterRequestEventListener(exceptionMeters),
+                new ResponseMeterRequestEventListener(responseMeters));
+
+        return listener;
+    }
+
+    private <T extends Annotation> T getClassLevelAnnotation(final Resource resource, final Class<T> annotationClazz) {
+        T annotation = null;
+
+        for (final Class<?> clazz : resource.getHandlerClasses()) {
+            annotation = clazz.getAnnotation(annotationClazz);
+
+            if (annotation != null) {
+                break;
+            }
+        }
+        return annotation;
+    }
+
+    private void registerTimedAnnotations(final ResourceMethod method, final Timed classLevelTimed) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+        if (classLevelTimed != null) {
+            registerTimers(method, definitionMethod, classLevelTimed);
+            return;
+        }
+
+        final Timed annotation = definitionMethod.getAnnotation(Timed.class);
+        if (annotation != null) {
+            registerTimers(method, definitionMethod, annotation);
+        }
+    }
+
+    private void registerTimers(ResourceMethod method, Method definitionMethod, Timed annotation) {
+        timers.putIfAbsent(EventTypeAndMethod.requestMethodStart(definitionMethod), timerMetric(metrics, method, annotation));
+        if (trackFilters) {
+            timers.putIfAbsent(EventTypeAndMethod.requestMatched(definitionMethod), timerMetric(metrics, method, annotation, REQUEST_FILTERING));
+            timers.putIfAbsent(EventTypeAndMethod.respFiltersStart(definitionMethod), timerMetric(metrics, method, annotation, RESPONSE_FILTERING));
+            timers.putIfAbsent(EventTypeAndMethod.finished(definitionMethod), timerMetric(metrics, method, annotation, TOTAL));
+        }
+    }
+
+    private void registerMeteredAnnotations(final ResourceMethod method, final Metered classLevelMetered) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+
+        if (classLevelMetered != null) {
+            meters.putIfAbsent(definitionMethod, meterMetric(metrics, method, classLevelMetered));
+            return;
+        }
+        final Metered annotation = definitionMethod.getAnnotation(Metered.class);
+
+        if (annotation != null) {
+            meters.putIfAbsent(definitionMethod, meterMetric(metrics, method, annotation));
+        }
+    }
+
+    private void registerExceptionMeteredAnnotations(final ResourceMethod method, final ExceptionMetered classLevelExceptionMetered) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+
+        if (classLevelExceptionMetered != null) {
+            exceptionMeters.putIfAbsent(definitionMethod, new ExceptionMeterMetric(metrics, method, classLevelExceptionMetered));
+            return;
+        }
+        final ExceptionMetered annotation = definitionMethod.getAnnotation(ExceptionMetered.class);
+
+        if (annotation != null) {
+            exceptionMeters.putIfAbsent(definitionMethod, new ExceptionMeterMetric(metrics, method, annotation));
+        }
+    }
+
+    private void registerResponseMeteredAnnotations(final ResourceMethod method, final ResponseMetered classLevelResponseMetered) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+
+        if (classLevelResponseMetered != null) {
+            responseMeters.putIfAbsent(definitionMethod, new ResponseMeterMetric(metrics, method, classLevelResponseMetered));
+            return;
+        }
+        final ResponseMetered annotation = definitionMethod.getAnnotation(ResponseMetered.class);
+
+        if (annotation != null) {
+            responseMeters.putIfAbsent(definitionMethod, new ResponseMeterMetric(metrics, method, annotation));
+        }
+    }
+
+    private Timer timerMetric(final MetricRegistry registry,
+                              final ResourceMethod method,
+                              final Timed timed,
+                              final String... suffixes) {
+        final String name = chooseName(timed.name(), timed.absolute(), method, suffixes);
+        return registry.timer(name, () -> new Timer(reservoirSupplier.get(), clock));
+    }
+
+    private Meter meterMetric(final MetricRegistry registry,
+                              final ResourceMethod method,
+                              final Metered metered) {
+        final String name = chooseName(metered.name(), metered.absolute(), method);
+        return registry.meter(name, () -> new Meter(clock));
+    }
+
+    protected static String chooseName(final String explicitName, final boolean absolute, final ResourceMethod method,
+                                       final String... suffixes) {
+        final Method definitionMethod = method.getInvocable().getDefinitionMethod();
+        final String metricName;
+        if (explicitName != null && !explicitName.isEmpty()) {
+            metricName = absolute ? explicitName : name(definitionMethod.getDeclaringClass(), explicitName);
+        } else {
+            metricName = name(definitionMethod.getDeclaringClass(), definitionMethod.getName());
+        }
+        return name(metricName, suffixes);
+    }
+
+    private static class EventTypeAndMethod {
+
+        private final RequestEvent.Type type;
+        private final Method method;
+
+        private EventTypeAndMethod(RequestEvent.Type type, Method method) {
+            this.type = type;
+            this.method = method;
+        }
+
+        static EventTypeAndMethod requestMethodStart(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.RESOURCE_METHOD_START, method);
+        }
+
+        static EventTypeAndMethod requestMatched(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.REQUEST_MATCHED, method);
+        }
+
+        static EventTypeAndMethod respFiltersStart(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.RESP_FILTERS_START, method);
+        }
+
+        static EventTypeAndMethod finished(Method method) {
+            return new EventTypeAndMethod(RequestEvent.Type.FINISHED, method);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+
+            EventTypeAndMethod that = (EventTypeAndMethod) o;
+
+            if (type != that.type) {
+                return false;
+            }
+            return method.equals(that.method);
+        }
+
+        @Override
+        public int hashCode() {
+            int result = type.hashCode();
+            result = 31 * result + method.hashCode();
+            return result;
+        }
+    }
+}
diff --git a/metrics-jersey31/src/main/java/io/dropwizard/metrics/jersey31/MetricsFeature.java b/metrics-jersey31/src/main/java/io/dropwizard/metrics/jersey31/MetricsFeature.java
new file mode 100644
index 0000000..87ae86e
--- /dev/null
+++ b/metrics-jersey31/src/main/java/io/dropwizard/metrics/jersey31/MetricsFeature.java
@@ -0,0 +1,97 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Reservoir;
+import com.codahale.metrics.SharedMetricRegistries;
+import jakarta.ws.rs.core.Feature;
+import jakarta.ws.rs.core.FeatureContext;
+
+import java.util.function.Supplier;
+
+/**
+ * A {@link Feature} which registers a {@link InstrumentedResourceMethodApplicationListener}
+ * for recording request events.
+ */
+public class MetricsFeature implements Feature {
+
+    private final MetricRegistry registry;
+    private final Clock clock;
+    private final boolean trackFilters;
+    private final Supplier<Reservoir> reservoirSupplier;
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     */
+    public MetricsFeature(MetricRegistry registry) {
+        this(registry, Clock.defaultClock());
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public MetricsFeature(MetricRegistry registry, Supplier<Reservoir> reservoirSupplier) {
+        this(registry, Clock.defaultClock(), false, reservoirSupplier);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock) {
+        this(registry, clock, false);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock, boolean trackFilters) {
+        this(registry, clock, trackFilters, ExponentiallyDecayingReservoir::new);
+    }
+
+    /*
+     * @param registry          the metrics registry where the metrics will be stored
+     * @param clock             the {@link Clock} to track time (used mostly in testing) in timers
+     * @param trackFilters      whether the processing time for request and response filters should be tracked
+     * @param reservoirSupplier Supplier for creating the {@link Reservoir} for {@link Timer timers}.
+     */
+    public MetricsFeature(MetricRegistry registry, Clock clock, boolean trackFilters, Supplier<Reservoir> reservoirSupplier) {
+        this.registry = registry;
+        this.clock = clock;
+        this.trackFilters = trackFilters;
+        this.reservoirSupplier = reservoirSupplier;
+    }
+
+    public MetricsFeature(String registryName) {
+        this(SharedMetricRegistries.getOrCreate(registryName));
+    }
+
+    /**
+     * A call-back method called when the feature is to be enabled in a given
+     * runtime configuration scope.
+     * <p>
+     * The responsibility of the feature is to properly update the supplied runtime configuration context
+     * and return {@code true} if the feature was successfully enabled or {@code false} otherwise.
+     * <p>
+     * Note that under some circumstances the feature may decide not to enable itself, which
+     * is indicated by returning {@code false}. In such case the configuration context does
+     * not add the feature to the collection of enabled features and a subsequent call to
+     * {@link jakarta.ws.rs.core.Configuration#isEnabled(jakarta.ws.rs.core.Feature)} or
+     * {@link jakarta.ws.rs.core.Configuration#isEnabled(Class)} method
+     * would return {@code false}.
+     * <p>
+     *
+     * @param context configurable context in which the feature should be enabled.
+     * @return {@code true} if the feature was successfully enabled, {@code false}
+     * otherwise.
+     */
+    @Override
+    public boolean configure(FeatureContext context) {
+        context.register(new InstrumentedResourceMethodApplicationListener(registry, clock, trackFilters, reservoirSupplier));
+        return true;
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/CustomReservoirImplementationTest.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/CustomReservoirImplementationTest.java
new file mode 100644
index 0000000..60190d0
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/CustomReservoirImplementationTest.java
@@ -0,0 +1,44 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.UniformReservoir;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedResourceTimedPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class CustomReservoirImplementationTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        return new ResourceConfig()
+                .register(new MetricsFeature(this.registry, UniformReservoir::new))
+                .register(InstrumentedResourceTimedPerClass.class);
+    }
+
+    @Test
+    public void timerHistogramIsUsingCustomReservoirImplementation() {
+        assertThat(target("timedPerClass").request().get(String.class)).isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedResourceTimedPerClass.class, "timedPerClass"));
+        assertThat(timer)
+                .extracting("histogram")
+                .extracting("reservoir")
+                .isInstanceOf(UniformReservoir.class);
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonFilterMetricsJerseyTest.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonFilterMetricsJerseyTest.java
new file mode 100644
index 0000000..e4bfc10
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonFilterMetricsJerseyTest.java
@@ -0,0 +1,162 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedFilteredResource;
+import io.dropwizard.metrics.jersey31.resources.TestRequestFilter;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig} with filter tracking
+ */
+public class SingletonFilterMetricsJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    private TestClock testClock;
+
+    @Override
+    protected Application configure() {
+        registry = new MetricRegistry();
+        testClock = new TestClock();
+        ResourceConfig config = new ResourceConfig();
+        config = config.register(new MetricsFeature(this.registry, testClock, true));
+        config = config.register(new TestRequestFilter(testClock));
+        config = config.register(new InstrumentedFilteredResource(testClock));
+        return config;
+    }
+
+    @Before
+    public void resetClock() {
+        testClock.tick = 0;
+    }
+
+    @Test
+    public void timedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void explicitNamesAreTimed() {
+        assertThat(target("named")
+                .request()
+                .get(String.class))
+                .isEqualTo("fancy");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "fancyName"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void absoluteNamesAreTimed() {
+        assertThat(target("absolute")
+                .request()
+                .get(String.class))
+                .isEqualTo("absolute");
+
+        final Timer timer = registry.timer("absolutelyFancy");
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(1);
+    }
+
+    @Test
+    public void requestFiltersOfTimedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "request", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void responseFiltersOfTimedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "response", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void totalTimeOfTimedMethodsIsTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "timed", "total"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(5);
+    }
+
+    @Test
+    public void requestFiltersOfNamedMethodsAreTimed() {
+        assertThat(target("named")
+                .request()
+                .get(String.class))
+                .isEqualTo("fancy");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.class, "fancyName", "request", "filtering"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void requestFiltersOfAbsoluteMethodsAreTimed() {
+        assertThat(target("absolute")
+                .request()
+                .get(String.class))
+                .isEqualTo("absolute");
+
+        final Timer timer = registry.timer(name("absolutelyFancy", "request", "filtering"));
+        assertThat(timer.getCount()).isEqualTo(1);
+        assertThat(timer.getSnapshot().getValues()[0]).isEqualTo(4);
+    }
+
+    @Test
+    public void subResourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedFilteredResource.InstrumentedFilteredSubResource.class,
+                "timed"));
+        assertThat(timer.getCount()).isEqualTo(1);
+
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsExceptionMeteredPerClassJerseyTest.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsExceptionMeteredPerClassJerseyTest.java
new file mode 100644
index 0000000..d84c835
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsExceptionMeteredPerClassJerseyTest.java
@@ -0,0 +1,98 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedResourceExceptionMeteredPerClass;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedSubResourceExceptionMeteredPerClass;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsExceptionMeteredPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceExceptionMeteredPerClass.class);
+
+        return config;
+    }
+
+    @Test
+    public void exceptionMeteredMethodsAreExceptionMetered() {
+        final Meter meter = registry.meter(name(InstrumentedResourceExceptionMeteredPerClass.class,
+                "exceptionMetered",
+                "exceptions"));
+
+        assertThat(target("exception-metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("fuh");
+
+        assertThat(meter.getCount()).isZero();
+
+        try {
+            target("exception-metered")
+                    .queryParam("splode", true)
+                    .request()
+                    .get(String.class);
+
+            failBecauseExceptionWasNotThrown(ProcessingException.class);
+        } catch (ProcessingException e) {
+            assertThat(e.getCause()).isInstanceOf(IOException.class);
+        }
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        final Meter meter = registry.meter(name(InstrumentedSubResourceExceptionMeteredPerClass.class,
+                "exceptionMetered",
+                "exceptions"));
+
+        assertThat(target("subresource/exception-metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("fuh");
+
+        assertThat(meter.getCount()).isZero();
+
+        try {
+            target("subresource/exception-metered")
+                    .queryParam("splode", true)
+                    .request()
+                    .get(String.class);
+
+            failBecauseExceptionWasNotThrown(ProcessingException.class);
+        } catch (ProcessingException e) {
+            assertThat(e.getCause()).isInstanceOf(IOException.class);
+        }
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsJerseyTest.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsJerseyTest.java
new file mode 100644
index 0000000..e96fd56
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsJerseyTest.java
@@ -0,0 +1,191 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedResource;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedSubResource;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.core.Application;
+import jakarta.ws.rs.core.Response;
+import org.glassfish.jersey.client.ClientResponse;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link org.glassfish.jersey.server.ResourceConfig}
+ */
+public class SingletonMetricsJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResource.class);
+
+        return config;
+    }
+
+    @Test
+    public void timedMethodsAreTimed() {
+        assertThat(target("timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedResource.class, "timed"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void meteredMethodsAreMetered() {
+        assertThat(target("metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("woo");
+
+        final Meter meter = registry.meter(name(InstrumentedResource.class, "metered"));
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void exceptionMeteredMethodsAreExceptionMetered() {
+        final Meter meter = registry.meter(name(InstrumentedResource.class,
+                "exceptionMetered",
+                "exceptions"));
+
+        assertThat(target("exception-metered")
+                .request()
+                .get(String.class))
+                .isEqualTo("fuh");
+
+        assertThat(meter.getCount()).isZero();
+
+        try {
+            target("exception-metered")
+                    .queryParam("splode", true)
+                    .request()
+                    .get(String.class);
+
+            failBecauseExceptionWasNotThrown(ProcessingException.class);
+        } catch (ProcessingException e) {
+            assertThat(e.getCause()).isInstanceOf(IOException.class);
+        }
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredMethodsAreMeteredWithCoarseLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredCoarse",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredCoarse",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("response-metered-coarse")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isZero();
+    }
+
+    @Test
+    public void responseMeteredMethodsAreMeteredWithDetailedLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "200-responses"));
+        final Meter meter201 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredDetailed",
+                "201-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(meter201.getCount()).isZero();
+        assertThat(target("response-metered-detailed")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+        assertThat(target("response-metered-detailed")
+                .queryParam("status_code", 201)
+                .request()
+                .get().getStatus())
+                .isEqualTo(201);
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isOne();
+        assertThat(meter201.getCount()).isOne();
+    }
+
+    @Test
+    public void responseMeteredMethodsAreMeteredWithAllLevel() {
+        final Meter meter2xx = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredAll",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedResource.class,
+                "responseMeteredAll",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("response-metered-all")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isOne();
+    }
+
+    @Test
+    public void testResourceNotFound() {
+        final Response response = target().path("not-found").request().get();
+        assertThat(response.getStatus()).isEqualTo(404);
+
+        try {
+            target().path("not-found").request().get(ClientResponse.class);
+            failBecauseExceptionWasNotThrown(NotFoundException.class);
+        } catch (NotFoundException e) {
+            assertThat(e.getMessage()).isEqualTo("HTTP 404 Not Found");
+        }
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/timed")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedSubResource.class, "timed"));
+        assertThat(timer.getCount()).isEqualTo(1);
+
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsMeteredPerClassJerseyTest.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsMeteredPerClassJerseyTest.java
new file mode 100644
index 0000000..d3e89de
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsMeteredPerClassJerseyTest.java
@@ -0,0 +1,66 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedResourceMeteredPerClass;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedSubResourceMeteredPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsMeteredPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceMeteredPerClass.class);
+
+        return config;
+    }
+
+    @Test
+    public void meteredPerClassMethodsAreMetered() {
+        assertThat(target("meteredPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Meter meter = registry.meter(name(InstrumentedResourceMeteredPerClass.class, "meteredPerClass"));
+
+        assertThat(meter.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/meteredPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Meter meter = registry.meter(name(InstrumentedSubResourceMeteredPerClass.class, "meteredPerClass"));
+        assertThat(meter.getCount()).isEqualTo(1);
+
+    }
+
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsResponseMeteredPerClassJerseyTest.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsResponseMeteredPerClassJerseyTest.java
new file mode 100644
index 0000000..4aa3877
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsResponseMeteredPerClassJerseyTest.java
@@ -0,0 +1,146 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import io.dropwizard.metrics.jersey31.exception.mapper.TestExceptionMapper;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedResourceResponseMeteredPerClass;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedSubResourceResponseMeteredPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsResponseMeteredPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceResponseMeteredPerClass.class);
+        config = config.register(new TestExceptionMapper());
+
+        return config;
+    }
+
+    @Test
+    public void responseMetered2xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered2xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        final Meter meter2xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered2xxPerClass",
+                "2xx-responses"));
+
+        assertThat(meter2xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMetered4xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered4xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(400);
+        assertThat(target("responseMeteredBadRequestPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(400);
+
+        final Meter meter4xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered4xxPerClass",
+                "4xx-responses"));
+        final Meter meterException4xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredBadRequestPerClass",
+                "4xx-responses"));
+
+        assertThat(meter4xx.getCount()).isEqualTo(1);
+        assertThat(meterException4xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMetered5xxPerClassMethodsAreMetered() {
+        assertThat(target("responseMetered5xxPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(500);
+
+        final Meter meter5xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMetered5xxPerClass",
+                "5xx-responses"));
+
+        assertThat(meter5xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredMappedExceptionPerClassMethodsAreMetered() {
+        assertThat(target("responseMeteredTestExceptionPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(500);
+
+        final Meter meterTestException = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredTestExceptionPerClass",
+                "5xx-responses"));
+
+        assertThat(meterTestException.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void responseMeteredUnmappedExceptionPerClassMethodsAreMetered() {
+        try {
+            target("responseMeteredRuntimeExceptionPerClass")
+                    .request()
+                    .get();
+            fail("expected RuntimeException");
+        } catch (Exception e) {
+            assertThat(e.getCause()).isInstanceOf(RuntimeException.class);
+        }
+
+        final Meter meterException5xx = registry.meter(name(InstrumentedResourceResponseMeteredPerClass.class,
+                "responseMeteredRuntimeExceptionPerClass",
+                "5xx-responses"));
+
+        assertThat(meterException5xx.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        final Meter meter2xx = registry.meter(name(InstrumentedSubResourceResponseMeteredPerClass.class,
+                "responseMeteredPerClass",
+                "2xx-responses"));
+        final Meter meter200 = registry.meter(name(InstrumentedSubResourceResponseMeteredPerClass.class,
+                "responseMeteredPerClass",
+                "200-responses"));
+
+        assertThat(meter2xx.getCount()).isZero();
+        assertThat(meter200.getCount()).isZero();
+        assertThat(target("subresource/responseMeteredPerClass")
+                .request()
+                .get().getStatus())
+                .isEqualTo(200);
+
+        assertThat(meter2xx.getCount()).isOne();
+        assertThat(meter200.getCount()).isOne();
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsTimedPerClassJerseyTest.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsTimedPerClassJerseyTest.java
new file mode 100644
index 0000000..a1e39ee
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/SingletonMetricsTimedPerClassJerseyTest.java
@@ -0,0 +1,66 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedResourceTimedPerClass;
+import io.dropwizard.metrics.jersey31.resources.InstrumentedSubResourceTimedPerClass;
+import jakarta.ws.rs.core.Application;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.Test;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests registering {@link InstrumentedResourceMethodApplicationListener} as a singleton
+ * in a Jersey {@link ResourceConfig}
+ */
+public class SingletonMetricsTimedPerClassJerseyTest extends JerseyTest {
+    static {
+        Logger.getLogger("org.glassfish.jersey").setLevel(Level.OFF);
+    }
+
+    private MetricRegistry registry;
+
+    @Override
+    protected Application configure() {
+        this.registry = new MetricRegistry();
+
+        ResourceConfig config = new ResourceConfig();
+
+        config = config.register(new MetricsFeature(this.registry));
+        config = config.register(InstrumentedResourceTimedPerClass.class);
+
+        return config;
+    }
+
+    @Test
+    public void timedPerClassMethodsAreTimed() {
+        assertThat(target("timedPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedResourceTimedPerClass.class, "timedPerClass"));
+
+        assertThat(timer.getCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void subresourcesFromLocatorsRegisterMetrics() {
+        assertThat(target("subresource/timedPerClass")
+                .request()
+                .get(String.class))
+                .isEqualTo("yay");
+
+        final Timer timer = registry.timer(name(InstrumentedSubResourceTimedPerClass.class, "timedPerClass"));
+        assertThat(timer.getCount()).isEqualTo(1);
+
+    }
+
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/TestClock.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/TestClock.java
new file mode 100644
index 0000000..b9d34e5
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/TestClock.java
@@ -0,0 +1,13 @@
+package io.dropwizard.metrics.jersey31;
+
+import com.codahale.metrics.Clock;
+
+public class TestClock extends Clock {
+
+    public long tick;
+
+    @Override
+    public long getTick() {
+        return tick;
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/exception/TestException.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/exception/TestException.java
new file mode 100644
index 0000000..1bf2427
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/exception/TestException.java
@@ -0,0 +1,9 @@
+package io.dropwizard.metrics.jersey31.exception;
+
+public class TestException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    public TestException(String message) {
+        super(message);
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/exception/mapper/TestExceptionMapper.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/exception/mapper/TestExceptionMapper.java
new file mode 100644
index 0000000..a3ecece
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/exception/mapper/TestExceptionMapper.java
@@ -0,0 +1,14 @@
+package io.dropwizard.metrics.jersey31.exception.mapper;
+
+import io.dropwizard.metrics.jersey31.exception.TestException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+
+@Provider
+public class TestExceptionMapper implements ExceptionMapper<TestException> {
+    @Override
+    public Response toResponse(TestException exception) {
+        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedFilteredResource.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedFilteredResource.java
new file mode 100644
index 0000000..6146c5f
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedFilteredResource.java
@@ -0,0 +1,61 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import io.dropwizard.metrics.jersey31.TestClock;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedFilteredResource {
+
+    private final TestClock testClock;
+
+    public InstrumentedFilteredResource(TestClock testClock) {
+        this.testClock = testClock;
+    }
+
+    @GET
+    @Timed
+    @Path("/timed")
+    public String timed() {
+        testClock.tick++;
+        return "yay";
+    }
+
+    @GET
+    @Timed(name = "fancyName")
+    @Path("/named")
+    public String named() {
+        testClock.tick++;
+        return "fancy";
+    }
+
+    @GET
+    @Timed(name = "absolutelyFancy", absolute = true)
+    @Path("/absolute")
+    public String absolute() {
+        testClock.tick++;
+        return "absolute";
+    }
+
+    @Path("/subresource")
+    public InstrumentedFilteredSubResource locateSubResource() {
+        return new InstrumentedFilteredSubResource();
+    }
+
+    @Produces(MediaType.TEXT_PLAIN)
+    public class InstrumentedFilteredSubResource {
+
+        @GET
+        @Timed
+        @Path("/timed")
+        public String timed() {
+            testClock.tick += 2;
+            return "yay";
+        }
+
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResource.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResource.java
new file mode 100644
index 0000000..60e3efb
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResource.java
@@ -0,0 +1,73 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.ExceptionMetered;
+import com.codahale.metrics.annotation.Metered;
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import java.io.IOException;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResource {
+    @GET
+    @Timed
+    @Path("/timed")
+    public String timed() {
+        return "yay";
+    }
+
+    @GET
+    @Metered
+    @Path("/metered")
+    public String metered() {
+        return "woo";
+    }
+
+    @GET
+    @ExceptionMetered(cause = IOException.class)
+    @Path("/exception-metered")
+    public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
+        if (splode) {
+            throw new IOException("AUGH");
+        }
+        return "fuh";
+    }
+
+    @GET
+    @ResponseMetered(level = DETAILED)
+    @Path("/response-metered-detailed")
+    public Response responseMeteredDetailed(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @GET
+    @ResponseMetered(level = COARSE)
+    @Path("/response-metered-coarse")
+    public Response responseMeteredCoarse(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @GET
+    @ResponseMetered(level = ALL)
+    @Path("/response-metered-all")
+    public Response responseMeteredAll(@QueryParam("status_code") @DefaultValue("200") int statusCode) {
+        return Response.status(Response.Status.fromStatusCode(statusCode)).build();
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResource locateSubResource() {
+        return new InstrumentedSubResource();
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceExceptionMeteredPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceExceptionMeteredPerClass.java
new file mode 100644
index 0000000..449c777
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceExceptionMeteredPerClass.java
@@ -0,0 +1,32 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.ExceptionMetered;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+
+import java.io.IOException;
+
+@ExceptionMetered(cause = IOException.class)
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceExceptionMeteredPerClass {
+
+    @GET
+    @Path("/exception-metered")
+    public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
+        if (splode) {
+            throw new IOException("AUGH");
+        }
+        return "fuh";
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceExceptionMeteredPerClass locateSubResource() {
+        return new InstrumentedSubResourceExceptionMeteredPerClass();
+    }
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceMeteredPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceMeteredPerClass.java
new file mode 100644
index 0000000..f9f6804
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceMeteredPerClass.java
@@ -0,0 +1,25 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.Metered;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Metered
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceMeteredPerClass {
+
+    @GET
+    @Path("/meteredPerClass")
+    public String meteredPerClass() {
+        return "yay";
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceMeteredPerClass locateSubResource() {
+        return new InstrumentedSubResourceMeteredPerClass();
+    }
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceResponseMeteredPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceResponseMeteredPerClass.java
new file mode 100644
index 0000000..8c10ba1
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceResponseMeteredPerClass.java
@@ -0,0 +1,58 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import io.dropwizard.metrics.jersey31.exception.TestException;
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+@ResponseMetered
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceResponseMeteredPerClass {
+
+    @GET
+    @Path("/responseMetered2xxPerClass")
+    public Response responseMetered2xxPerClass() {
+        return Response.ok().build();
+    }
+
+    @GET
+    @Path("/responseMetered4xxPerClass")
+    public Response responseMetered4xxPerClass() {
+        return Response.status(Response.Status.BAD_REQUEST).build();
+    }
+
+    @GET
+    @Path("/responseMetered5xxPerClass")
+    public Response responseMetered5xxPerClass() {
+        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+    }
+
+    @GET
+    @Path("/responseMeteredBadRequestPerClass")
+    public String responseMeteredBadRequestPerClass() {
+        throw new BadRequestException();
+    }
+
+    @GET
+    @Path("/responseMeteredRuntimeExceptionPerClass")
+    public String responseMeteredRuntimeExceptionPerClass() {
+        throw new RuntimeException();
+    }
+
+    @GET
+    @Path("/responseMeteredTestExceptionPerClass")
+    public String responseMeteredTestExceptionPerClass() {
+        throw new TestException("test");
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceResponseMeteredPerClass locateSubResource() {
+        return new InstrumentedSubResourceResponseMeteredPerClass();
+    }
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceTimedPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceTimedPerClass.java
new file mode 100644
index 0000000..fb45f28
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedResourceTimedPerClass.java
@@ -0,0 +1,25 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Timed
+@Path("/")
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedResourceTimedPerClass {
+
+    @GET
+    @Path("/timedPerClass")
+    public String timedPerClass() {
+        return "yay";
+    }
+
+    @Path("/subresource")
+    public InstrumentedSubResourceTimedPerClass locateSubResource() {
+        return new InstrumentedSubResourceTimedPerClass();
+    }
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResource.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResource.java
new file mode 100644
index 0000000..36c8e6a
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResource.java
@@ -0,0 +1,19 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResource {
+
+    @GET
+    @Timed
+    @Path("/timed")
+    public String timed() {
+        return "yay";
+    }
+
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceExceptionMeteredPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceExceptionMeteredPerClass.java
new file mode 100644
index 0000000..e983ade
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceExceptionMeteredPerClass.java
@@ -0,0 +1,24 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.ExceptionMetered;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+
+import java.io.IOException;
+
+@ExceptionMetered(cause = IOException.class)
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceExceptionMeteredPerClass {
+    @GET
+    @Path("/exception-metered")
+    public String exceptionMetered(@QueryParam("splode") @DefaultValue("false") boolean splode) throws IOException {
+        if (splode) {
+            throw new IOException("AUGH");
+        }
+        return "fuh";
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceMeteredPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceMeteredPerClass.java
new file mode 100644
index 0000000..5282641
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceMeteredPerClass.java
@@ -0,0 +1,17 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.Metered;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Metered
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceMeteredPerClass {
+    @GET
+    @Path("/meteredPerClass")
+    public String meteredPerClass() {
+        return "yay";
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceResponseMeteredPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceResponseMeteredPerClass.java
new file mode 100644
index 0000000..cf42959
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceResponseMeteredPerClass.java
@@ -0,0 +1,20 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+
+@ResponseMetered(level = ALL)
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceResponseMeteredPerClass {
+    @GET
+    @Path("/responseMeteredPerClass")
+    public Response responseMeteredPerClass() {
+        return Response.status(Response.Status.OK).build();
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceTimedPerClass.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceTimedPerClass.java
new file mode 100644
index 0000000..0c115f2
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/InstrumentedSubResourceTimedPerClass.java
@@ -0,0 +1,17 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import com.codahale.metrics.annotation.Timed;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+
+@Timed
+@Produces(MediaType.TEXT_PLAIN)
+public class InstrumentedSubResourceTimedPerClass {
+    @GET
+    @Path("/timedPerClass")
+    public String timedPerClass() {
+        return "yay";
+    }
+}
diff --git a/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/TestRequestFilter.java b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/TestRequestFilter.java
new file mode 100644
index 0000000..9ceb8ec
--- /dev/null
+++ b/metrics-jersey31/src/test/java/io/dropwizard/metrics/jersey31/resources/TestRequestFilter.java
@@ -0,0 +1,21 @@
+package io.dropwizard.metrics.jersey31.resources;
+
+import io.dropwizard.metrics.jersey31.TestClock;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+
+import java.io.IOException;
+
+public class TestRequestFilter implements ContainerRequestFilter {
+
+    private final TestClock testClock;
+
+    public TestRequestFilter(TestClock testClock) {
+        this.testClock = testClock;
+    }
+
+    @Override
+    public void filter(ContainerRequestContext containerRequestContext) throws IOException {
+        testClock.tick += 4;
+    }
+}
diff --git a/metrics-jetty10/pom.xml b/metrics-jetty10/pom.xml
new file mode 100644
index 0000000..b05136d
--- /dev/null
+++ b/metrics-jetty10/pom.xml
@@ -0,0 +1,123 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jetty10</artifactId>
+    <name>Metrics Integration for Jetty 10.x and higher</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of extensions for Jetty 10.x and higher which provide instrumentation of thread pools, connector
+        metrics, and application latency and utilization.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.jetty10</javaModuleName>
+
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+
+        <slf4j.version>2.0.11</slf4j.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-bom</artifactId>
+                <version>${jetty10.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.slf4j</groupId>
+                <artifactId>slf4j-api</artifactId>
+                <version>${slf4j.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-io</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-util</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty.toolchain</groupId>
+            <artifactId>jetty-servlet-api</artifactId>
+            <version>4.0.6</version>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-client</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactory.java b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedConnectionFactory.java
similarity index 65%
rename from metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactory.java
rename to metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedConnectionFactory.java
index 02d3f8c..481abb5 100644
--- a/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactory.java
+++ b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedConnectionFactory.java
@@ -1,5 +1,6 @@
-package com.codahale.metrics.jetty9;
+package io.dropwizard.metrics.jetty10;
 
+import com.codahale.metrics.Counter;
 import com.codahale.metrics.Timer;
 import org.eclipse.jetty.io.Connection;
 import org.eclipse.jetty.io.EndPoint;
@@ -7,13 +8,21 @@ import org.eclipse.jetty.server.ConnectionFactory;
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.util.component.ContainerLifeCycle;
 
+import java.util.List;
+
 public class InstrumentedConnectionFactory extends ContainerLifeCycle implements ConnectionFactory {
     private final ConnectionFactory connectionFactory;
     private final Timer timer;
+    private final Counter counter;
 
     public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer) {
+        this(connectionFactory, timer, null);
+    }
+
+    public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer, Counter counter) {
         this.connectionFactory = connectionFactory;
         this.timer = timer;
+        this.counter = counter;
         addBean(connectionFactory);
     }
 
@@ -22,20 +31,31 @@ public class InstrumentedConnectionFactory extends ContainerLifeCycle implements
         return connectionFactory.getProtocol();
     }
 
+    @Override
+    public List<String> getProtocols() {
+        return connectionFactory.getProtocols();
+    }
+
     @Override
     public Connection newConnection(Connector connector, EndPoint endPoint) {
         final Connection connection = connectionFactory.newConnection(connector, endPoint);
-        connection.addListener(new Connection.Listener() {
+        connection.addEventListener(new Connection.Listener() {
             private Timer.Context context;
 
             @Override
             public void onOpened(Connection connection) {
                 this.context = timer.time();
+                if (counter != null) {
+                    counter.inc();
+                }
             }
 
             @Override
             public void onClosed(Connection connection) {
                 context.stop();
+                if (counter != null) {
+                    counter.dec();
+                }
             }
         });
         return connection;
diff --git a/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedHandler.java b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedHandler.java
new file mode 100644
index 0000000..bb849e0
--- /dev/null
+++ b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedHandler.java
@@ -0,0 +1,444 @@
+package io.dropwizard.metrics.jetty10;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.server.AsyncContextState;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.HttpChannelState;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.handler.HandlerWrapper;
+
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+/**
+ * A Jetty {@link Handler} which records various metrics about an underlying {@link Handler}
+ * instance.
+ */
+public class InstrumentedHandler extends HandlerWrapper {
+    private static final String NAME_REQUESTS = "requests";
+    private static final String NAME_DISPATCHES = "dispatches";
+    private static final String NAME_ACTIVE_REQUESTS = "active-requests";
+    private static final String NAME_ACTIVE_DISPATCHES = "active-dispatches";
+    private static final String NAME_ACTIVE_SUSPENDED = "active-suspended";
+    private static final String NAME_ASYNC_DISPATCHES = "async-dispatches";
+    private static final String NAME_ASYNC_TIMEOUTS = "async-timeouts";
+    private static final String NAME_1XX_RESPONSES = "1xx-responses";
+    private static final String NAME_2XX_RESPONSES = "2xx-responses";
+    private static final String NAME_3XX_RESPONSES = "3xx-responses";
+    private static final String NAME_4XX_RESPONSES = "4xx-responses";
+    private static final String NAME_5XX_RESPONSES = "5xx-responses";
+    private static final String NAME_GET_REQUESTS = "get-requests";
+    private static final String NAME_POST_REQUESTS = "post-requests";
+    private static final String NAME_HEAD_REQUESTS = "head-requests";
+    private static final String NAME_PUT_REQUESTS = "put-requests";
+    private static final String NAME_DELETE_REQUESTS = "delete-requests";
+    private static final String NAME_OPTIONS_REQUESTS = "options-requests";
+    private static final String NAME_TRACE_REQUESTS = "trace-requests";
+    private static final String NAME_CONNECT_REQUESTS = "connect-requests";
+    private static final String NAME_MOVE_REQUESTS = "move-requests";
+    private static final String NAME_OTHER_REQUESTS = "other-requests";
+    private static final String NAME_PERCENT_4XX_1M = "percent-4xx-1m";
+    private static final String NAME_PERCENT_4XX_5M = "percent-4xx-5m";
+    private static final String NAME_PERCENT_4XX_15M = "percent-4xx-15m";
+    private static final String NAME_PERCENT_5XX_1M = "percent-5xx-1m";
+    private static final String NAME_PERCENT_5XX_5M = "percent-5xx-5m";
+    private static final String NAME_PERCENT_5XX_15M = "percent-5xx-15m";
+    private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+    private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+
+    private final MetricRegistry metricRegistry;
+
+    private String name;
+    private final String prefix;
+
+    // the requests handled by this handler, excluding active
+    private Timer requests;
+
+    // the number of dispatches seen by this handler, excluding active
+    private Timer dispatches;
+
+    // the number of active requests
+    private Counter activeRequests;
+
+    // the number of active dispatches
+    private Counter activeDispatches;
+
+    // the number of requests currently suspended.
+    private Counter activeSuspended;
+
+    // the number of requests that have been asynchronously dispatched
+    private Meter asyncDispatches;
+
+    // the number of requests that expired while suspended
+    private Meter asyncTimeouts;
+
+    private final ResponseMeteredLevel responseMeteredLevel;
+    private List<Meter> responses;
+    private Map<Integer, Meter> responseCodeMeters;
+
+    private Timer getRequests;
+    private Timer postRequests;
+    private Timer headRequests;
+    private Timer putRequests;
+    private Timer deleteRequests;
+    private Timer optionsRequests;
+    private Timer traceRequests;
+    private Timer connectRequests;
+    private Timer moveRequests;
+    private Timer otherRequests;
+
+    private AsyncListener listener;
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     */
+    public InstrumentedHandler(MetricRegistry registry) {
+        this(registry, null);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     */
+    public InstrumentedHandler(MetricRegistry registry, String prefix) {
+        this(registry, prefix, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    public InstrumentedHandler(MetricRegistry registry, String prefix, ResponseMeteredLevel responseMeteredLevel) {
+        this.metricRegistry = registry;
+        this.prefix = prefix;
+        this.responseMeteredLevel = responseMeteredLevel;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        final String prefix = getMetricPrefix();
+
+        this.requests = metricRegistry.timer(name(prefix, NAME_REQUESTS));
+        this.dispatches = metricRegistry.timer(name(prefix, NAME_DISPATCHES));
+
+        this.activeRequests = metricRegistry.counter(name(prefix, NAME_ACTIVE_REQUESTS));
+        this.activeDispatches = metricRegistry.counter(name(prefix, NAME_ACTIVE_DISPATCHES));
+        this.activeSuspended = metricRegistry.counter(name(prefix, NAME_ACTIVE_SUSPENDED));
+
+        this.asyncDispatches = metricRegistry.meter(name(prefix, NAME_ASYNC_DISPATCHES));
+        this.asyncTimeouts = metricRegistry.meter(name(prefix, NAME_ASYNC_TIMEOUTS));
+
+        this.responseCodeMeters = DETAILED_METER_LEVELS.contains(responseMeteredLevel) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+
+        this.getRequests = metricRegistry.timer(name(prefix, NAME_GET_REQUESTS));
+        this.postRequests = metricRegistry.timer(name(prefix, NAME_POST_REQUESTS));
+        this.headRequests = metricRegistry.timer(name(prefix, NAME_HEAD_REQUESTS));
+        this.putRequests = metricRegistry.timer(name(prefix, NAME_PUT_REQUESTS));
+        this.deleteRequests = metricRegistry.timer(name(prefix, NAME_DELETE_REQUESTS));
+        this.optionsRequests = metricRegistry.timer(name(prefix, NAME_OPTIONS_REQUESTS));
+        this.traceRequests = metricRegistry.timer(name(prefix, NAME_TRACE_REQUESTS));
+        this.connectRequests = metricRegistry.timer(name(prefix, NAME_CONNECT_REQUESTS));
+        this.moveRequests = metricRegistry.timer(name(prefix, NAME_MOVE_REQUESTS));
+        this.otherRequests = metricRegistry.timer(name(prefix, NAME_OTHER_REQUESTS));
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            this.responses = Collections.unmodifiableList(Arrays.asList(
+                    metricRegistry.meter(name(prefix, NAME_1XX_RESPONSES)), // 1xx
+                    metricRegistry.meter(name(prefix, NAME_2XX_RESPONSES)), // 2xx
+                    metricRegistry.meter(name(prefix, NAME_3XX_RESPONSES)), // 3xx
+                    metricRegistry.meter(name(prefix, NAME_4XX_RESPONSES)), // 4xx
+                    metricRegistry.meter(name(prefix, NAME_5XX_RESPONSES))  // 5xx
+            ));
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_15M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_15M), new RatioGauge() {
+                @Override
+                public Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
+        } else {
+            this.responses = Collections.emptyList();
+        }
+
+
+        this.listener = new AsyncAttachingListener();
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_SUSPENDED));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_TIMEOUTS));
+        metricRegistry.remove(name(prefix, NAME_1XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_2XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_3XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_4XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_5XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_GET_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_POST_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_HEAD_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PUT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DELETE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OPTIONS_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_TRACE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_CONNECT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_MOVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OTHER_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_15M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_15M));
+
+        if (responseCodeMeters != null) {
+            responseCodeMeters.keySet().stream()
+                    .map(sc -> name(getMetricPrefix(), String.format("%d-responses", sc)))
+                    .forEach(metricRegistry::remove);
+        }
+        super.doStop();
+    }
+
+    @Override
+    public void handle(String path,
+                       Request request,
+                       HttpServletRequest httpRequest,
+                       HttpServletResponse httpResponse) throws IOException, ServletException {
+
+        activeDispatches.inc();
+
+        final long start;
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isInitial()) {
+            // new request
+            activeRequests.inc();
+            start = request.getTimeStamp();
+            state.addListener(listener);
+        } else {
+            // resumed request
+            start = System.currentTimeMillis();
+            activeSuspended.dec();
+            if (state.getState() == HttpChannelState.State.HANDLING) {
+                asyncDispatches.mark();
+            }
+        }
+
+        try {
+            super.handle(path, request, httpRequest, httpResponse);
+        } finally {
+            final long now = System.currentTimeMillis();
+            final long dispatched = now - start;
+
+            activeDispatches.dec();
+            dispatches.update(dispatched, TimeUnit.MILLISECONDS);
+
+            if (state.isSuspended()) {
+                activeSuspended.inc();
+            } else if (state.isInitial()) {
+                updateResponses(httpRequest, httpResponse, start, request.isHandled());
+            }
+            // else onCompletion will handle it.
+        }
+    }
+
+    private Timer requestTimer(String method) {
+        final HttpMethod m = HttpMethod.fromString(method);
+        if (m == null) {
+            return otherRequests;
+        } else {
+            switch (m) {
+                case GET:
+                    return getRequests;
+                case POST:
+                    return postRequests;
+                case PUT:
+                    return putRequests;
+                case HEAD:
+                    return headRequests;
+                case DELETE:
+                    return deleteRequests;
+                case OPTIONS:
+                    return optionsRequests;
+                case TRACE:
+                    return traceRequests;
+                case CONNECT:
+                    return connectRequests;
+                case MOVE:
+                    return moveRequests;
+                default:
+                    return otherRequests;
+            }
+        }
+    }
+
+    private void updateResponses(HttpServletRequest request, HttpServletResponse response, long start, boolean isHandled) {
+        if (isHandled) {
+            mark(response.getStatus());
+        } else {
+            mark(404);; // will end up with a 404 response sent by HttpChannel.handle
+        }
+        activeRequests.dec();
+        final long elapsedTime = System.currentTimeMillis() - start;
+        requests.update(elapsedTime, TimeUnit.MILLISECONDS);
+        requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
+    }
+
+    private void mark(int statusCode) {
+        if (DETAILED_METER_LEVELS.contains(responseMeteredLevel)) {
+            getResponseCodeMeter(statusCode).mark();
+        }
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            final int responseStatus = statusCode / 100;
+            if (responseStatus >= 1 && responseStatus <= 5) {
+                responses.get(responseStatus - 1).mark();
+            }
+        }
+    }
+
+    private Meter getResponseCodeMeter(int statusCode) {
+        return responseCodeMeters
+                .computeIfAbsent(statusCode, sc -> metricRegistry
+                        .meter(name(getMetricPrefix(), String.format("%d-responses", sc))));
+    }
+
+    private String getMetricPrefix() {
+        return this.prefix == null ? name(getHandler().getClass(), name) : name(this.prefix, name);
+    }
+
+    private class AsyncAttachingListener implements AsyncListener {
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+            event.getAsyncContext().addListener(new InstrumentedAsyncListener());
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {}
+    };
+
+    private class InstrumentedAsyncListener implements AsyncListener {
+        private final long startTime;
+
+        InstrumentedAsyncListener() {
+            this.startTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            asyncTimeouts.mark();
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
+            final HttpServletRequest request = (HttpServletRequest) state.getRequest();
+            final HttpServletResponse response = (HttpServletResponse) state.getResponse();
+            updateResponses(request, response, startTime, true);
+            if (!state.getHttpChannelState().isSuspended()) {
+                activeSuspended.dec();
+            }
+        }
+    }
+}
diff --git a/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedHttpChannelListener.java b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedHttpChannelListener.java
new file mode 100644
index 0000000..decb8bb
--- /dev/null
+++ b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedHttpChannelListener.java
@@ -0,0 +1,424 @@
+package io.dropwizard.metrics.jetty10;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.server.AsyncContextState;
+import org.eclipse.jetty.server.HttpChannel.Listener;
+import org.eclipse.jetty.server.HttpChannelState;
+import org.eclipse.jetty.server.Request;
+
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+/**
+ * A Jetty {@link org.eclipse.jetty.server.HttpChannel.Listener} implementation which records various metrics about
+ * underlying channel instance. Unlike {@link InstrumentedHandler} that uses internal API, this class should be
+ * future proof. To install it, just add instance of this class to {@link org.eclipse.jetty.server.Connector} as bean.
+ *
+ * @since TBD
+ */
+public class InstrumentedHttpChannelListener
+        implements Listener {
+    private static final String START_ATTR = InstrumentedHttpChannelListener.class.getName() + ".start";
+    private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+    private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+
+    private final MetricRegistry metricRegistry;
+
+    // the requests handled by this handler, excluding active
+    private final Timer requests;
+
+    // the number of dispatches seen by this handler, excluding active
+    private final Timer dispatches;
+
+    // the number of active requests
+    private final Counter activeRequests;
+
+    // the number of active dispatches
+    private final Counter activeDispatches;
+
+    // the number of requests currently suspended.
+    private final Counter activeSuspended;
+
+    // the number of requests that have been asynchronously dispatched
+    private final Meter asyncDispatches;
+
+    // the number of requests that expired while suspended
+    private final Meter asyncTimeouts;
+
+    private final ResponseMeteredLevel responseMeteredLevel;
+    private final List<Meter> responses;
+    private final Map<Integer, Meter> responseCodeMeters;
+    private final String prefix;
+    private final Timer getRequests;
+    private final Timer postRequests;
+    private final Timer headRequests;
+    private final Timer putRequests;
+    private final Timer deleteRequests;
+    private final Timer optionsRequests;
+    private final Timer traceRequests;
+    private final Timer connectRequests;
+    private final Timer moveRequests;
+    private final Timer otherRequests;
+
+    private final AsyncListener listener;
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry) {
+        this(registry, null, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param pref     the prefix to use for the metrics names
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry, String pref) {
+        this(registry, pref, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param pref     the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry, String pref, ResponseMeteredLevel responseMeteredLevel) {
+        this.metricRegistry = registry;
+
+        this.prefix = (pref == null) ? getClass().getName() : pref;
+
+        this.requests = metricRegistry.timer(name(prefix, "requests"));
+        this.dispatches = metricRegistry.timer(name(prefix, "dispatches"));
+
+        this.activeRequests = metricRegistry.counter(name(prefix, "active-requests"));
+        this.activeDispatches = metricRegistry.counter(name(prefix, "active-dispatches"));
+        this.activeSuspended = metricRegistry.counter(name(prefix, "active-suspended"));
+
+        this.asyncDispatches = metricRegistry.meter(name(prefix, "async-dispatches"));
+        this.asyncTimeouts = metricRegistry.meter(name(prefix, "async-timeouts"));
+
+        this.responseMeteredLevel = responseMeteredLevel;
+        this.responseCodeMeters = DETAILED_METER_LEVELS.contains(responseMeteredLevel) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+        this.responses = COARSE_METER_LEVELS.contains(responseMeteredLevel) ?
+                Collections.unmodifiableList(Arrays.asList(
+                        registry.meter(name(prefix, "1xx-responses")), // 1xx
+                        registry.meter(name(prefix, "2xx-responses")), // 2xx
+                        registry.meter(name(prefix, "3xx-responses")), // 3xx
+                        registry.meter(name(prefix, "4xx-responses")), // 4xx
+                        registry.meter(name(prefix, "5xx-responses"))  // 5xx
+                )) : Collections.emptyList();
+
+        this.getRequests = metricRegistry.timer(name(prefix, "get-requests"));
+        this.postRequests = metricRegistry.timer(name(prefix, "post-requests"));
+        this.headRequests = metricRegistry.timer(name(prefix, "head-requests"));
+        this.putRequests = metricRegistry.timer(name(prefix, "put-requests"));
+        this.deleteRequests = metricRegistry.timer(name(prefix, "delete-requests"));
+        this.optionsRequests = metricRegistry.timer(name(prefix, "options-requests"));
+        this.traceRequests = metricRegistry.timer(name(prefix, "trace-requests"));
+        this.connectRequests = metricRegistry.timer(name(prefix, "connect-requests"));
+        this.moveRequests = metricRegistry.timer(name(prefix, "move-requests"));
+        this.otherRequests = metricRegistry.timer(name(prefix, "other-requests"));
+
+        metricRegistry.register(name(prefix, "percent-4xx-1m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getOneMinuteRate(),
+                        requests.getOneMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-4xx-5m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getFiveMinuteRate(),
+                        requests.getFiveMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-4xx-15m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getFifteenMinuteRate(),
+                        requests.getFifteenMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-1m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(4).getOneMinuteRate(),
+                        requests.getOneMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-5m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(4).getFiveMinuteRate(),
+                        requests.getFiveMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-15m"), new RatioGauge() {
+            @Override
+            public Ratio getRatio() {
+                return Ratio.of(responses.get(4).getFifteenMinuteRate(),
+                        requests.getFifteenMinuteRate());
+            }
+        });
+
+        this.listener = new AsyncAttachingListener();
+    }
+
+    @Override
+    public void onRequestBegin(final Request request) {
+
+    }
+
+    @Override
+    public void onBeforeDispatch(final Request request) {
+        before(request);
+    }
+
+    @Override
+    public void onDispatchFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onAfterDispatch(final Request request) {
+        after(request);
+    }
+
+    @Override
+    public void onRequestContent(final Request request, final ByteBuffer content) {
+
+    }
+
+    @Override
+    public void onRequestContentEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestTrailers(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onResponseBegin(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseCommit(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseContent(final Request request, final ByteBuffer content) {
+
+    }
+
+    @Override
+    public void onResponseEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onComplete(final Request request) {
+
+    }
+
+    private void before(final Request request) {
+        activeDispatches.inc();
+
+        final long start;
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isInitial()) {
+            // new request
+            activeRequests.inc();
+            start = request.getTimeStamp();
+            state.addListener(listener);
+        } else {
+            // resumed request
+            start = System.currentTimeMillis();
+            activeSuspended.dec();
+            if (state.isAsyncStarted()) {
+                asyncDispatches.mark();
+            }
+        }
+        request.setAttribute(START_ATTR, start);
+    }
+
+    private void after(final Request request) {
+        final long start = (long) request.getAttribute(START_ATTR);
+        final long now = System.currentTimeMillis();
+        final long dispatched = now - start;
+
+        activeDispatches.dec();
+        dispatches.update(dispatched, TimeUnit.MILLISECONDS);
+
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isSuspended()) {
+            activeSuspended.inc();
+        } else if (state.isInitial()) {
+            updateResponses(request, request.getResponse(), start, request.isHandled());
+        }
+        // else onCompletion will handle it.
+    }
+
+    private void updateResponses(HttpServletRequest request, HttpServletResponse response, long start, boolean isHandled) {
+        if (isHandled) {
+            mark(response.getStatus());
+        } else {
+            mark(404); // will end up with a 404 response sent by HttpChannel.handle
+        }
+        activeRequests.dec();
+        final long elapsedTime = System.currentTimeMillis() - start;
+        requests.update(elapsedTime, TimeUnit.MILLISECONDS);
+        requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
+    }
+
+    private void mark(int statusCode) {
+        if (DETAILED_METER_LEVELS.contains(responseMeteredLevel)) {
+            getResponseCodeMeter(statusCode).mark();
+        }
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            final int responseStatus = statusCode / 100;
+            if (responseStatus >= 1 && responseStatus <= 5) {
+                responses.get(responseStatus - 1).mark();
+            }
+        }
+    }
+
+    private Meter getResponseCodeMeter(int statusCode) {
+        return responseCodeMeters
+                .computeIfAbsent(statusCode, sc -> metricRegistry
+                        .meter(name(prefix, String.format("%d-responses", sc))));
+    }
+
+    private Timer requestTimer(String method) {
+        final HttpMethod m = HttpMethod.fromString(method);
+        if (m == null) {
+            return otherRequests;
+        } else {
+            switch (m) {
+                case GET:
+                    return getRequests;
+                case POST:
+                    return postRequests;
+                case PUT:
+                    return putRequests;
+                case HEAD:
+                    return headRequests;
+                case DELETE:
+                    return deleteRequests;
+                case OPTIONS:
+                    return optionsRequests;
+                case TRACE:
+                    return traceRequests;
+                case CONNECT:
+                    return connectRequests;
+                case MOVE:
+                    return moveRequests;
+                default:
+                    return otherRequests;
+            }
+        }
+    }
+
+    private class AsyncAttachingListener implements AsyncListener {
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+            event.getAsyncContext().addListener(new InstrumentedAsyncListener());
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {}
+    };
+
+    private class InstrumentedAsyncListener implements AsyncListener {
+        private final long startTime;
+
+        InstrumentedAsyncListener() {
+            this.startTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            asyncTimeouts.mark();
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
+            final HttpServletRequest request = (HttpServletRequest) state.getRequest();
+            final HttpServletResponse response = (HttpServletResponse) state.getResponse();
+            updateResponses(request, response, startTime, true);
+            if (!state.getHttpChannelState().isSuspended()) {
+                activeSuspended.dec();
+            }
+        }
+    }
+}
diff --git a/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedQueuedThreadPool.java b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedQueuedThreadPool.java
new file mode 100644
index 0000000..92951cb
--- /dev/null
+++ b/metrics-jetty10/src/main/java/io/dropwizard/metrics/jetty10/InstrumentedQueuedThreadPool.java
@@ -0,0 +1,177 @@
+package io.dropwizard.metrics.jetty10;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import org.eclipse.jetty.util.annotation.Name;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ThreadFactory;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+public class InstrumentedQueuedThreadPool extends QueuedThreadPool {
+    private static final String NAME_UTILIZATION = "utilization";
+    private static final String NAME_UTILIZATION_MAX = "utilization-max";
+    private static final String NAME_SIZE = "size";
+    private static final String NAME_JOBS = "jobs";
+    private static final String NAME_JOBS_QUEUE_UTILIZATION = "jobs-queue-utilization";
+
+    private final MetricRegistry metricRegistry;
+    private String prefix;
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry) {
+        this(registry, 200);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads) {
+        this(registry, maxThreads, 8);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads) {
+        this(registry, maxThreads, minThreads, 60000);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue) {
+        this(registry, maxThreads, minThreads, 60000, queue);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout) {
+        this(registry, maxThreads, minThreads, idleTimeout, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue) {
+        this(registry, maxThreads, minThreads, idleTimeout, queue, (ThreadGroup) null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("prefix") String prefix) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, null, null, prefix);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadFactory") ThreadFactory threadFactory) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, null, threadFactory);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, threadGroup);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup) {
+        this(registry, maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup,
+                                        @Name("threadFactory") ThreadFactory threadFactory) {
+        this(registry, maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, threadFactory, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup,
+                                        @Name("threadFactory") ThreadFactory threadFactory,
+                                        @Name("prefix") String prefix) {
+        super(maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, threadFactory);
+        this.metricRegistry = registry;
+        this.prefix = prefix;
+    }
+
+    public String getPrefix() {
+        return prefix;
+    }
+
+    public void setPrefix(String prefix) {
+        this.prefix = prefix;
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.register(name(prefix, NAME_UTILIZATION), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(getThreads() - getIdleThreads(), getThreads());
+            }
+        });
+        metricRegistry.register(name(prefix, NAME_UTILIZATION_MAX), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(getThreads() - getIdleThreads(), getMaxThreads());
+            }
+        });
+        // This assumes the QueuedThreadPool is using a BlockingArrayQueue or
+        // ArrayBlockingQueue for its queue, and is therefore a constant-time operation.
+        metricRegistry.registerGauge(name(prefix, NAME_SIZE), this::getThreads);
+        metricRegistry.registerGauge(name(prefix, NAME_JOBS), () -> getQueue().size());
+        metricRegistry.register(name(prefix, NAME_JOBS_QUEUE_UTILIZATION), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                BlockingQueue<Runnable> queue = getQueue();
+                return Ratio.of(queue.size(), queue.size() + queue.remainingCapacity());
+            }
+        });
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION));
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION_MAX));
+        metricRegistry.remove(name(prefix, NAME_SIZE));
+        metricRegistry.remove(name(prefix, NAME_JOBS));
+        metricRegistry.remove(name(prefix, NAME_JOBS_QUEUE_UTILIZATION));
+
+        super.doStop();
+    }
+
+    private String getMetricPrefix() {
+        return this.prefix == null ? name(QueuedThreadPool.class, getName()) : name(this.prefix, getName());
+    }
+}
diff --git a/metrics-jetty9-legacy/src/test/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactoryTest.java b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedConnectionFactoryTest.java
similarity index 72%
rename from metrics-jetty9-legacy/src/test/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactoryTest.java
rename to metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedConnectionFactoryTest.java
index 5d825dd..b5d1b0c 100644
--- a/metrics-jetty9-legacy/src/test/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactoryTest.java
+++ b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedConnectionFactoryTest.java
@@ -1,5 +1,6 @@
-package com.codahale.metrics.jetty9;
+package io.dropwizard.metrics.jetty10;
 
+import com.codahale.metrics.Counter;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.Timer;
 import org.eclipse.jetty.client.HttpClient;
@@ -19,7 +20,6 @@ import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.io.PrintWriter;
 
-import static com.codahale.metrics.MetricRegistry.name;
 import static org.assertj.core.api.Assertions.assertThat;
 
 public class InstrumentedConnectionFactoryTest {
@@ -27,7 +27,8 @@ public class InstrumentedConnectionFactoryTest {
     private final Server server = new Server();
     private final ServerConnector connector =
             new ServerConnector(server, new InstrumentedConnectionFactory(new HttpConnectionFactory(),
-                                                                          registry.timer("http.connections")));
+                    registry.timer("http.connections"),
+                    registry.counter("http.active-connections")));
     private final HttpClient client = new HttpClient();
 
     @Before
@@ -66,8 +67,27 @@ public class InstrumentedConnectionFactoryTest {
 
         Thread.sleep(100); // make sure the connection is closed
 
-        final Timer timer = registry.timer(name("http.connections"));
+        final Timer timer = registry.timer(MetricRegistry.name("http.connections"));
         assertThat(timer.getCount())
                 .isEqualTo(1);
     }
+
+    @Test
+    public void instrumentsActiveConnections() throws Exception {
+        final Counter counter = registry.counter("http.active-connections");
+
+        final ContentResponse response = client.GET("http://localhost:" + connector.getLocalPort() + "/hello");
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertThat(counter.getCount())
+                .isEqualTo(1);
+
+        client.stop(); // close the connection
+
+        Thread.sleep(100); // make sure the connection is closed
+
+        assertThat(counter.getCount())
+                .isEqualTo(0);
+    }
 }
diff --git a/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedHandlerTest.java b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedHandlerTest.java
new file mode 100644
index 0000000..248bf6e
--- /dev/null
+++ b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedHandlerTest.java
@@ -0,0 +1,244 @@
+package io.dropwizard.metrics.jetty10;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+public class InstrumentedHandlerTest {
+    private final HttpClient client = new HttpClient();
+    private final MetricRegistry registry = new MetricRegistry();
+    private final Server server = new Server();
+    private final ServerConnector connector = new ServerConnector(server);
+    private final InstrumentedHandler handler = new InstrumentedHandler(registry, null, ALL);
+
+    @Before
+    public void setUp() throws Exception {
+        handler.setName("handler");
+        handler.setHandler(new TestHandler());
+        server.addConnector(connector);
+        server.setHandler(handler);
+        server.start();
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void hasAName() throws Exception {
+        assertThat(handler.getName())
+                .isEqualTo("handler");
+    }
+
+    @Test
+    public void createsAndRemovesMetricsForTheHandler() throws Exception {
+        final ContentResponse response = client.GET(uri("/hello"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(404);
+
+        assertThat(registry.getNames())
+                .containsOnly(
+                        MetricRegistry.name(TestHandler.class, "handler.1xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.2xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.3xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.4xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.404-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.5xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-1m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-5m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-15m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-1m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-5m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-15m"),
+                        MetricRegistry.name(TestHandler.class, "handler.requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-suspended"),
+                        MetricRegistry.name(TestHandler.class, "handler.async-dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.async-timeouts"),
+                        MetricRegistry.name(TestHandler.class, "handler.get-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.put-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.trace-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.other-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.connect-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.head-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.post-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.options-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.delete-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.move-requests")
+                );
+
+        server.stop();
+
+        assertThat(registry.getNames())
+                .isEmpty();
+    }
+
+    @Test
+    public void responseTimesAreRecordedForBlockingResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/blocking"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertResponseTimesValid();
+    }
+
+    @Test
+    public void doStopDoesNotThrowNPE() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, null, ALL);
+        handler.setHandler(new TestHandler());
+
+        assertThatCode(handler::doStop).doesNotThrowAnyException();
+    }
+
+    @Test
+    public void gaugesAreRegisteredWithResponseMeteredLevelCoarse() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, "coarse", COARSE);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).containsKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    public void gaugesAreNotRegisteredWithResponseMeteredLevelDetailed() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, "detailed", DETAILED);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).doesNotContainKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    @Ignore("flaky on virtual machines")
+    public void responseTimesAreRecordedForAsyncResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/async"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertResponseTimesValid();
+    }
+
+    private void assertResponseTimesValid() {
+        assertThat(registry.getMeters().get(metricName() + ".200-responses")
+                .getCount()).isGreaterThan(0L);
+
+        assertThat(registry.getTimers().get(metricName() + ".get-requests")
+                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+
+        assertThat(registry.getTimers().get(metricName() + ".requests")
+                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+    }
+
+    private String uri(String path) {
+        return "http://localhost:" + connector.getLocalPort() + path;
+    }
+
+    private String metricName() {
+        return MetricRegistry.name(TestHandler.class.getName(), "handler");
+    }
+
+    /**
+     * test handler.
+     * <p>
+     * Supports
+     * <p>
+     * /blocking - uses the standard servlet api
+     * /async - uses the 3.1 async api to complete the request
+     * <p>
+     * all other requests will return 404
+     */
+    private static class TestHandler extends AbstractHandler {
+        @Override
+        public void handle(
+                String path,
+                Request request,
+                final HttpServletRequest httpServletRequest,
+                final HttpServletResponse httpServletResponse
+        ) throws IOException, ServletException {
+            switch (path) {
+                case "/blocking":
+                    request.setHandled(true);
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    httpServletResponse.setStatus(200);
+                    httpServletResponse.setContentType("text/plain");
+                    httpServletResponse.getWriter().write("some content from the blocking request\n");
+                    break;
+                case "/async":
+                    request.setHandled(true);
+                    final AsyncContext context = request.startAsync();
+                    Thread t = new Thread(() -> {
+                        try {
+                            Thread.sleep(100);
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                        }
+                        httpServletResponse.setStatus(200);
+                        httpServletResponse.setContentType("text/plain");
+                        final ServletOutputStream servletOutputStream;
+                        try {
+                            servletOutputStream = httpServletResponse.getOutputStream();
+                            servletOutputStream.setWriteListener(
+                                    new WriteListener() {
+                                        @Override
+                                        public void onWritePossible() throws IOException {
+                                            servletOutputStream.write("some content from the async\n"
+                                                    .getBytes(StandardCharsets.UTF_8));
+                                            context.complete();
+                                        }
+
+                                        @Override
+                                        public void onError(Throwable throwable) {
+                                            context.complete();
+                                        }
+                                    }
+                            );
+                        } catch (IOException e) {
+                            context.complete();
+                        }
+                    });
+                    t.start();
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+}
diff --git a/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedHttpChannelListenerTest.java b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedHttpChannelListenerTest.java
new file mode 100644
index 0000000..800c9ff
--- /dev/null
+++ b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedHttpChannelListenerTest.java
@@ -0,0 +1,212 @@
+package io.dropwizard.metrics.jetty10;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedHttpChannelListenerTest {
+    private final HttpClient client = new HttpClient();
+    private final Server server = new Server();
+    private final ServerConnector connector = new ServerConnector(server);
+    private final TestHandler handler = new TestHandler();
+    private MetricRegistry registry;
+
+    @Before
+    public void setUp() throws Exception {
+        registry = new MetricRegistry();
+        connector.addBean(new InstrumentedHttpChannelListener(registry, MetricRegistry.name(TestHandler.class, "handler"), ALL));
+        server.addConnector(connector);
+        server.setHandler(handler);
+        server.start();
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void createsMetricsForTheHandler() throws Exception {
+        final ContentResponse response = client.GET(uri("/hello"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(404);
+
+        assertThat(registry.getNames())
+                .containsOnly(
+                        metricName("1xx-responses"),
+                        metricName("2xx-responses"),
+                        metricName("3xx-responses"),
+                        metricName("404-responses"),
+                        metricName("4xx-responses"),
+                        metricName("5xx-responses"),
+                        metricName("percent-4xx-1m"),
+                        metricName("percent-4xx-5m"),
+                        metricName("percent-4xx-15m"),
+                        metricName("percent-5xx-1m"),
+                        metricName("percent-5xx-5m"),
+                        metricName("percent-5xx-15m"),
+                        metricName("requests"),
+                        metricName("active-suspended"),
+                        metricName("async-dispatches"),
+                        metricName("async-timeouts"),
+                        metricName("get-requests"),
+                        metricName("put-requests"),
+                        metricName("active-dispatches"),
+                        metricName("trace-requests"),
+                        metricName("other-requests"),
+                        metricName("connect-requests"),
+                        metricName("dispatches"),
+                        metricName("head-requests"),
+                        metricName("post-requests"),
+                        metricName("options-requests"),
+                        metricName("active-requests"),
+                        metricName("delete-requests"),
+                        metricName("move-requests")
+                );
+    }
+
+
+    @Test
+    public void responseTimesAreRecordedForBlockingResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/blocking"));
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.getMediaType()).isEqualTo("text/plain");
+        assertThat(response.getContentAsString()).isEqualTo("some content from the blocking request");
+
+        assertResponseTimesValid();
+    }
+
+    @Test
+    public void responseTimesAreRecordedForAsyncResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/async"));
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.getMediaType()).isEqualTo("text/plain");
+        assertThat(response.getContentAsString()).isEqualTo("some content from the async");
+
+        assertResponseTimesValid();
+    }
+
+    private void assertResponseTimesValid() {
+        try {
+            Thread.sleep(100);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+
+        assertThat(registry.getMeters().get(metricName("2xx-responses"))
+                .getCount()).isPositive();
+        assertThat(registry.getMeters().get(metricName("200-responses"))
+                .getCount()).isPositive();
+
+        assertThat(registry.getTimers().get(metricName("get-requests"))
+                .getSnapshot().getMedian()).isPositive();
+
+        assertThat(registry.getTimers().get(metricName("requests"))
+                .getSnapshot().getMedian()).isPositive();
+    }
+
+    private String uri(String path) {
+        return "http://localhost:" + connector.getLocalPort() + path;
+    }
+
+    private String metricName(String metricName) {
+        return MetricRegistry.name(TestHandler.class.getName(), "handler", metricName);
+    }
+
+    /**
+     * test handler.
+     * <p>
+     * Supports
+     * <p>
+     * /blocking - uses the standard servlet api
+     * /async - uses the 3.1 async api to complete the request
+     * <p>
+     * all other requests will return 404
+     */
+    private static class TestHandler extends AbstractHandler {
+        @Override
+        public void handle(
+                String path,
+                Request request,
+                final HttpServletRequest httpServletRequest,
+                final HttpServletResponse httpServletResponse) throws IOException {
+            switch (path) {
+                case "/blocking":
+                    request.setHandled(true);
+                    httpServletResponse.setStatus(200);
+                    httpServletResponse.setContentType("text/plain");
+                    httpServletResponse.getWriter().write("some content from the blocking request");
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        httpServletResponse.setStatus(500);
+                        Thread.currentThread().interrupt();
+                    }
+                    break;
+                case "/async":
+                    request.setHandled(true);
+                    final AsyncContext context = request.startAsync();
+                    Thread t = new Thread(() -> {
+                        httpServletResponse.setStatus(200);
+                        httpServletResponse.setContentType("text/plain");
+                        try {
+                            Thread.sleep(100);
+                        } catch (InterruptedException e) {
+                            httpServletResponse.setStatus(500);
+                            Thread.currentThread().interrupt();
+                        }
+                        final ServletOutputStream servletOutputStream;
+                        try {
+                            servletOutputStream = httpServletResponse.getOutputStream();
+                            servletOutputStream.setWriteListener(
+                                    new WriteListener() {
+                                        @Override
+                                        public void onWritePossible() throws IOException {
+                                            servletOutputStream.write("some content from the async"
+                                                    .getBytes(StandardCharsets.UTF_8));
+                                            context.complete();
+                                        }
+
+                                        @Override
+                                        public void onError(Throwable throwable) {
+                                            context.complete();
+                                        }
+                                    }
+                            );
+                        } catch (IOException e) {
+                            context.complete();
+                        }
+                    });
+                    t.start();
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+}
diff --git a/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedQueuedThreadPoolTest.java b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedQueuedThreadPoolTest.java
new file mode 100644
index 0000000..4902e19
--- /dev/null
+++ b/metrics-jetty10/src/test/java/io/dropwizard/metrics/jetty10/InstrumentedQueuedThreadPoolTest.java
@@ -0,0 +1,49 @@
+package io.dropwizard.metrics.jetty10;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedQueuedThreadPoolTest {
+    private static final String PREFIX = "prefix";
+
+    private MetricRegistry metricRegistry;
+    private InstrumentedQueuedThreadPool iqtp;
+
+    @Before
+    public void setUp() {
+        metricRegistry = new MetricRegistry();
+        iqtp = new InstrumentedQueuedThreadPool(metricRegistry);
+    }
+
+    @Test
+    public void customMetricsPrefix() throws Exception {
+        iqtp.setPrefix(PREFIX);
+        iqtp.start();
+
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("Custom metrics prefix doesn't match")
+                .allSatisfy(name -> assertThat(name).startsWith(PREFIX));
+
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
+    }
+
+    @Test
+    public void metricsPrefixBackwardCompatible() throws Exception {
+        iqtp.start();
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .allSatisfy(name -> assertThat(name).startsWith(QueuedThreadPool.class.getName()));
+
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
+    }
+}
diff --git a/metrics-jetty11/pom.xml b/metrics-jetty11/pom.xml
new file mode 100644
index 0000000..eb0ae81
--- /dev/null
+++ b/metrics-jetty11/pom.xml
@@ -0,0 +1,123 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jetty11</artifactId>
+    <name>Metrics Integration for Jetty 11.x and higher</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of extensions for Jetty 11.x and higher which provide instrumentation of thread pools, connector
+        metrics, and application latency and utilization.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.jetty11</javaModuleName>
+
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+
+        <slf4j.version>2.0.11</slf4j.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-bom</artifactId>
+                <version>${jetty11.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.slf4j</groupId>
+                <artifactId>slf4j-api</artifactId>
+                <version>${slf4j.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-io</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-util</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty.toolchain</groupId>
+            <artifactId>jetty-jakarta-servlet-api</artifactId>
+            <version>5.0.2</version>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-client</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedConnectionFactory.java b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedConnectionFactory.java
new file mode 100644
index 0000000..bce951e
--- /dev/null
+++ b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedConnectionFactory.java
@@ -0,0 +1,63 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Timer;
+import org.eclipse.jetty.io.Connection;
+import org.eclipse.jetty.io.EndPoint;
+import org.eclipse.jetty.server.ConnectionFactory;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.util.component.ContainerLifeCycle;
+
+import java.util.List;
+
+public class InstrumentedConnectionFactory extends ContainerLifeCycle implements ConnectionFactory {
+    private final ConnectionFactory connectionFactory;
+    private final Timer timer;
+    private final Counter counter;
+
+    public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer) {
+        this(connectionFactory, timer, null);
+    }
+
+    public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer, Counter counter) {
+        this.connectionFactory = connectionFactory;
+        this.timer = timer;
+        this.counter = counter;
+        addBean(connectionFactory);
+    }
+
+    @Override
+    public String getProtocol() {
+        return connectionFactory.getProtocol();
+    }
+
+    @Override
+    public List<String> getProtocols() {
+        return connectionFactory.getProtocols();
+    }
+
+    @Override
+    public Connection newConnection(Connector connector, EndPoint endPoint) {
+        final Connection connection = connectionFactory.newConnection(connector, endPoint);
+        connection.addEventListener(new Connection.Listener() {
+            private Timer.Context context;
+
+            @Override
+            public void onOpened(Connection connection) {
+                this.context = timer.time();
+                if (counter != null) {
+                    counter.inc();
+                }
+            }
+
+            @Override
+            public void onClosed(Connection connection) {
+                context.stop();
+                if (counter != null) {
+                    counter.dec();
+                }
+            }
+        });
+        return connection;
+    }
+}
diff --git a/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedHandler.java b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedHandler.java
new file mode 100644
index 0000000..7914166
--- /dev/null
+++ b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedHandler.java
@@ -0,0 +1,443 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import jakarta.servlet.AsyncEvent;
+import jakarta.servlet.AsyncListener;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.server.AsyncContextState;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.HttpChannelState;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.handler.HandlerWrapper;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+/**
+ * A Jetty {@link Handler} which records various metrics about an underlying {@link Handler}
+ * instance.
+ */
+public class InstrumentedHandler extends HandlerWrapper {
+    private static final String NAME_REQUESTS = "requests";
+    private static final String NAME_DISPATCHES = "dispatches";
+    private static final String NAME_ACTIVE_REQUESTS = "active-requests";
+    private static final String NAME_ACTIVE_DISPATCHES = "active-dispatches";
+    private static final String NAME_ACTIVE_SUSPENDED = "active-suspended";
+    private static final String NAME_ASYNC_DISPATCHES = "async-dispatches";
+    private static final String NAME_ASYNC_TIMEOUTS = "async-timeouts";
+    private static final String NAME_1XX_RESPONSES = "1xx-responses";
+    private static final String NAME_2XX_RESPONSES = "2xx-responses";
+    private static final String NAME_3XX_RESPONSES = "3xx-responses";
+    private static final String NAME_4XX_RESPONSES = "4xx-responses";
+    private static final String NAME_5XX_RESPONSES = "5xx-responses";
+    private static final String NAME_GET_REQUESTS = "get-requests";
+    private static final String NAME_POST_REQUESTS = "post-requests";
+    private static final String NAME_HEAD_REQUESTS = "head-requests";
+    private static final String NAME_PUT_REQUESTS = "put-requests";
+    private static final String NAME_DELETE_REQUESTS = "delete-requests";
+    private static final String NAME_OPTIONS_REQUESTS = "options-requests";
+    private static final String NAME_TRACE_REQUESTS = "trace-requests";
+    private static final String NAME_CONNECT_REQUESTS = "connect-requests";
+    private static final String NAME_MOVE_REQUESTS = "move-requests";
+    private static final String NAME_OTHER_REQUESTS = "other-requests";
+    private static final String NAME_PERCENT_4XX_1M = "percent-4xx-1m";
+    private static final String NAME_PERCENT_4XX_5M = "percent-4xx-5m";
+    private static final String NAME_PERCENT_4XX_15M = "percent-4xx-15m";
+    private static final String NAME_PERCENT_5XX_1M = "percent-5xx-1m";
+    private static final String NAME_PERCENT_5XX_5M = "percent-5xx-5m";
+    private static final String NAME_PERCENT_5XX_15M = "percent-5xx-15m";
+    private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+    private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+
+    private final MetricRegistry metricRegistry;
+
+    private String name;
+    private final String prefix;
+
+    // the requests handled by this handler, excluding active
+    private Timer requests;
+
+    // the number of dispatches seen by this handler, excluding active
+    private Timer dispatches;
+
+    // the number of active requests
+    private Counter activeRequests;
+
+    // the number of active dispatches
+    private Counter activeDispatches;
+
+    // the number of requests currently suspended.
+    private Counter activeSuspended;
+
+    // the number of requests that have been asynchronously dispatched
+    private Meter asyncDispatches;
+
+    // the number of requests that expired while suspended
+    private Meter asyncTimeouts;
+
+    private final ResponseMeteredLevel responseMeteredLevel;
+    private List<Meter> responses;
+    private Map<Integer, Meter> responseCodeMeters;
+
+    private Timer getRequests;
+    private Timer postRequests;
+    private Timer headRequests;
+    private Timer putRequests;
+    private Timer deleteRequests;
+    private Timer optionsRequests;
+    private Timer traceRequests;
+    private Timer connectRequests;
+    private Timer moveRequests;
+    private Timer otherRequests;
+
+    private AsyncListener listener;
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     */
+    public InstrumentedHandler(MetricRegistry registry) {
+        this(registry, null);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     */
+    public InstrumentedHandler(MetricRegistry registry, String prefix) {
+        this(registry, prefix, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    public InstrumentedHandler(MetricRegistry registry, String prefix, ResponseMeteredLevel responseMeteredLevel) {
+        this.responseMeteredLevel = responseMeteredLevel;
+        this.metricRegistry = registry;
+        this.prefix = prefix;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        final String prefix = getMetricPrefix();
+
+        this.requests = metricRegistry.timer(name(prefix, NAME_REQUESTS));
+        this.dispatches = metricRegistry.timer(name(prefix, NAME_DISPATCHES));
+
+        this.activeRequests = metricRegistry.counter(name(prefix, NAME_ACTIVE_REQUESTS));
+        this.activeDispatches = metricRegistry.counter(name(prefix, NAME_ACTIVE_DISPATCHES));
+        this.activeSuspended = metricRegistry.counter(name(prefix, NAME_ACTIVE_SUSPENDED));
+
+        this.asyncDispatches = metricRegistry.meter(name(prefix, NAME_ASYNC_DISPATCHES));
+        this.asyncTimeouts = metricRegistry.meter(name(prefix, NAME_ASYNC_TIMEOUTS));
+
+        this.responseCodeMeters = DETAILED_METER_LEVELS.contains(responseMeteredLevel) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+
+        this.getRequests = metricRegistry.timer(name(prefix, NAME_GET_REQUESTS));
+        this.postRequests = metricRegistry.timer(name(prefix, NAME_POST_REQUESTS));
+        this.headRequests = metricRegistry.timer(name(prefix, NAME_HEAD_REQUESTS));
+        this.putRequests = metricRegistry.timer(name(prefix, NAME_PUT_REQUESTS));
+        this.deleteRequests = metricRegistry.timer(name(prefix, NAME_DELETE_REQUESTS));
+        this.optionsRequests = metricRegistry.timer(name(prefix, NAME_OPTIONS_REQUESTS));
+        this.traceRequests = metricRegistry.timer(name(prefix, NAME_TRACE_REQUESTS));
+        this.connectRequests = metricRegistry.timer(name(prefix, NAME_CONNECT_REQUESTS));
+        this.moveRequests = metricRegistry.timer(name(prefix, NAME_MOVE_REQUESTS));
+        this.otherRequests = metricRegistry.timer(name(prefix, NAME_OTHER_REQUESTS));
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            this.responses = Collections.unmodifiableList(Arrays.asList(
+                            metricRegistry.meter(name(prefix, NAME_1XX_RESPONSES)), // 1xx
+                            metricRegistry.meter(name(prefix, NAME_2XX_RESPONSES)), // 2xx
+                            metricRegistry.meter(name(prefix, NAME_3XX_RESPONSES)), // 3xx
+                            metricRegistry.meter(name(prefix, NAME_4XX_RESPONSES)), // 4xx
+                            metricRegistry.meter(name(prefix, NAME_5XX_RESPONSES))  // 5xx
+                    ));
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_15M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_15M), new RatioGauge() {
+                @Override
+                public Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
+        } else {
+            this.responses = Collections.emptyList();
+        }
+
+
+        this.listener = new AsyncAttachingListener();
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_SUSPENDED));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_TIMEOUTS));
+        metricRegistry.remove(name(prefix, NAME_1XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_2XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_3XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_4XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_5XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_GET_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_POST_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_HEAD_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PUT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DELETE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OPTIONS_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_TRACE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_CONNECT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_MOVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OTHER_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_15M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_15M));
+
+        if (responseCodeMeters != null) {
+            responseCodeMeters.keySet().stream()
+                    .map(sc -> name(getMetricPrefix(), String.format("%d-responses", sc)))
+                    .forEach(metricRegistry::remove);
+        }
+        super.doStop();
+    }
+
+    @Override
+    public void handle(String path,
+                       Request request,
+                       HttpServletRequest httpRequest,
+                       HttpServletResponse httpResponse) throws IOException, ServletException {
+
+        activeDispatches.inc();
+
+        final long start;
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isInitial()) {
+            // new request
+            activeRequests.inc();
+            start = request.getTimeStamp();
+            state.addListener(listener);
+        } else {
+            // resumed request
+            start = System.currentTimeMillis();
+            activeSuspended.dec();
+            if (state.getState() == HttpChannelState.State.HANDLING) {
+                asyncDispatches.mark();
+            }
+        }
+
+        try {
+            super.handle(path, request, httpRequest, httpResponse);
+        } finally {
+            final long now = System.currentTimeMillis();
+            final long dispatched = now - start;
+
+            activeDispatches.dec();
+            dispatches.update(dispatched, TimeUnit.MILLISECONDS);
+
+            if (state.isSuspended()) {
+                activeSuspended.inc();
+            } else if (state.isInitial()) {
+                updateResponses(httpRequest, httpResponse, start, request.isHandled());
+            }
+            // else onCompletion will handle it.
+        }
+    }
+
+    private Timer requestTimer(String method) {
+        final HttpMethod m = HttpMethod.fromString(method);
+        if (m == null) {
+            return otherRequests;
+        } else {
+            switch (m) {
+                case GET:
+                    return getRequests;
+                case POST:
+                    return postRequests;
+                case PUT:
+                    return putRequests;
+                case HEAD:
+                    return headRequests;
+                case DELETE:
+                    return deleteRequests;
+                case OPTIONS:
+                    return optionsRequests;
+                case TRACE:
+                    return traceRequests;
+                case CONNECT:
+                    return connectRequests;
+                case MOVE:
+                    return moveRequests;
+                default:
+                    return otherRequests;
+            }
+        }
+    }
+
+    private void updateResponses(HttpServletRequest request, HttpServletResponse response, long start, boolean isHandled) {
+        if (isHandled) {
+            mark(response.getStatus());
+        } else {
+            mark(404);; // will end up with a 404 response sent by HttpChannel.handle
+        }
+        activeRequests.dec();
+        final long elapsedTime = System.currentTimeMillis() - start;
+        requests.update(elapsedTime, TimeUnit.MILLISECONDS);
+        requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
+    }
+
+    private void mark(int statusCode) {
+        if (DETAILED_METER_LEVELS.contains(responseMeteredLevel)) {
+            getResponseCodeMeter(statusCode).mark();
+        }
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            final int responseStatus = statusCode / 100;
+            if (responseStatus >= 1 && responseStatus <= 5) {
+                responses.get(responseStatus - 1).mark();
+            }
+        }
+    }
+
+    private Meter getResponseCodeMeter(int statusCode) {
+        return responseCodeMeters
+                .computeIfAbsent(statusCode, sc -> metricRegistry
+                        .meter(name(getMetricPrefix(), String.format("%d-responses", sc))));
+    }
+
+    private String getMetricPrefix() {
+        return this.prefix == null ? name(getHandler().getClass(), name) : name(this.prefix, name);
+    }
+
+    private class AsyncAttachingListener implements AsyncListener {
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+            event.getAsyncContext().addListener(new InstrumentedAsyncListener());
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {}
+    };
+
+    private class InstrumentedAsyncListener implements AsyncListener {
+        private final long startTime;
+
+        InstrumentedAsyncListener() {
+            this.startTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            asyncTimeouts.mark();
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
+            final HttpServletRequest request = (HttpServletRequest) state.getRequest();
+            final HttpServletResponse response = (HttpServletResponse) state.getResponse();
+            updateResponses(request, response, startTime, true);
+            if (!state.getHttpChannelState().isSuspended()) {
+                activeSuspended.dec();
+            }
+        }
+    }
+}
diff --git a/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedHttpChannelListener.java b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedHttpChannelListener.java
new file mode 100644
index 0000000..ce36ebb
--- /dev/null
+++ b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedHttpChannelListener.java
@@ -0,0 +1,424 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import jakarta.servlet.AsyncEvent;
+import jakarta.servlet.AsyncListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.server.AsyncContextState;
+import org.eclipse.jetty.server.HttpChannel.Listener;
+import org.eclipse.jetty.server.HttpChannelState;
+import org.eclipse.jetty.server.Request;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+/**
+ * A Jetty {@link org.eclipse.jetty.server.HttpChannel.Listener} implementation which records various metrics about
+ * underlying channel instance. Unlike {@link InstrumentedHandler} that uses internal API, this class should be
+ * future proof. To install it, just add instance of this class to {@link org.eclipse.jetty.server.Connector} as bean.
+ *
+ * @since TBD
+ */
+public class InstrumentedHttpChannelListener
+        implements Listener {
+    private static final String START_ATTR = InstrumentedHttpChannelListener.class.getName() + ".start";
+    private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+    private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+
+    private final MetricRegistry metricRegistry;
+
+    // the requests handled by this handler, excluding active
+    private final Timer requests;
+
+    // the number of dispatches seen by this handler, excluding active
+    private final Timer dispatches;
+
+    // the number of active requests
+    private final Counter activeRequests;
+
+    // the number of active dispatches
+    private final Counter activeDispatches;
+
+    // the number of requests currently suspended.
+    private final Counter activeSuspended;
+
+    // the number of requests that have been asynchronously dispatched
+    private final Meter asyncDispatches;
+
+    // the number of requests that expired while suspended
+    private final Meter asyncTimeouts;
+
+    private final ResponseMeteredLevel responseMeteredLevel;
+    private final List<Meter> responses;
+    private final Map<Integer, Meter> responseCodeMeters;
+    private final String prefix;
+    private final Timer getRequests;
+    private final Timer postRequests;
+    private final Timer headRequests;
+    private final Timer putRequests;
+    private final Timer deleteRequests;
+    private final Timer optionsRequests;
+    private final Timer traceRequests;
+    private final Timer connectRequests;
+    private final Timer moveRequests;
+    private final Timer otherRequests;
+
+    private final AsyncListener listener;
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry) {
+        this(registry, null, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param pref     the prefix to use for the metrics names
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry, String pref) {
+        this(registry, pref, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param pref     the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry, String pref, ResponseMeteredLevel responseMeteredLevel) {
+        this.metricRegistry = registry;
+
+        this.prefix = (pref == null) ? getClass().getName() : pref;
+
+        this.requests = metricRegistry.timer(name(prefix, "requests"));
+        this.dispatches = metricRegistry.timer(name(prefix, "dispatches"));
+
+        this.activeRequests = metricRegistry.counter(name(prefix, "active-requests"));
+        this.activeDispatches = metricRegistry.counter(name(prefix, "active-dispatches"));
+        this.activeSuspended = metricRegistry.counter(name(prefix, "active-suspended"));
+
+        this.asyncDispatches = metricRegistry.meter(name(prefix, "async-dispatches"));
+        this.asyncTimeouts = metricRegistry.meter(name(prefix, "async-timeouts"));
+
+        this.responseMeteredLevel = responseMeteredLevel;
+        this.responseCodeMeters = DETAILED_METER_LEVELS.contains(responseMeteredLevel) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+        this.responses = COARSE_METER_LEVELS.contains(responseMeteredLevel) ?
+                Collections.unmodifiableList(Arrays.asList(
+                        registry.meter(name(prefix, "1xx-responses")), // 1xx
+                        registry.meter(name(prefix, "2xx-responses")), // 2xx
+                        registry.meter(name(prefix, "3xx-responses")), // 3xx
+                        registry.meter(name(prefix, "4xx-responses")), // 4xx
+                        registry.meter(name(prefix, "5xx-responses"))  // 5xx
+                )) : Collections.emptyList();
+
+        this.getRequests = metricRegistry.timer(name(prefix, "get-requests"));
+        this.postRequests = metricRegistry.timer(name(prefix, "post-requests"));
+        this.headRequests = metricRegistry.timer(name(prefix, "head-requests"));
+        this.putRequests = metricRegistry.timer(name(prefix, "put-requests"));
+        this.deleteRequests = metricRegistry.timer(name(prefix, "delete-requests"));
+        this.optionsRequests = metricRegistry.timer(name(prefix, "options-requests"));
+        this.traceRequests = metricRegistry.timer(name(prefix, "trace-requests"));
+        this.connectRequests = metricRegistry.timer(name(prefix, "connect-requests"));
+        this.moveRequests = metricRegistry.timer(name(prefix, "move-requests"));
+        this.otherRequests = metricRegistry.timer(name(prefix, "other-requests"));
+
+        metricRegistry.register(name(prefix, "percent-4xx-1m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getOneMinuteRate(),
+                        requests.getOneMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-4xx-5m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getFiveMinuteRate(),
+                        requests.getFiveMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-4xx-15m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getFifteenMinuteRate(),
+                        requests.getFifteenMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-1m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(4).getOneMinuteRate(),
+                        requests.getOneMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-5m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(4).getFiveMinuteRate(),
+                        requests.getFiveMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-15m"), new RatioGauge() {
+            @Override
+            public Ratio getRatio() {
+                return Ratio.of(responses.get(4).getFifteenMinuteRate(),
+                        requests.getFifteenMinuteRate());
+            }
+        });
+
+        this.listener = new AsyncAttachingListener();
+    }
+
+    @Override
+    public void onRequestBegin(final Request request) {
+
+    }
+
+    @Override
+    public void onBeforeDispatch(final Request request) {
+        before(request);
+    }
+
+    @Override
+    public void onDispatchFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onAfterDispatch(final Request request) {
+        after(request);
+    }
+
+    @Override
+    public void onRequestContent(final Request request, final ByteBuffer content) {
+
+    }
+
+    @Override
+    public void onRequestContentEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestTrailers(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onResponseBegin(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseCommit(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseContent(final Request request, final ByteBuffer content) {
+
+    }
+
+    @Override
+    public void onResponseEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onComplete(final Request request) {
+
+    }
+
+    private void before(final Request request) {
+        activeDispatches.inc();
+
+        final long start;
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isInitial()) {
+            // new request
+            activeRequests.inc();
+            start = request.getTimeStamp();
+            state.addListener(listener);
+        } else {
+            // resumed request
+            start = System.currentTimeMillis();
+            activeSuspended.dec();
+            if (state.isAsyncStarted()) {
+                asyncDispatches.mark();
+            }
+        }
+        request.setAttribute(START_ATTR, start);
+    }
+
+    private void after(final Request request) {
+        final long start = (long) request.getAttribute(START_ATTR);
+        final long now = System.currentTimeMillis();
+        final long dispatched = now - start;
+
+        activeDispatches.dec();
+        dispatches.update(dispatched, TimeUnit.MILLISECONDS);
+
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isSuspended()) {
+            activeSuspended.inc();
+        } else if (state.isInitial()) {
+            updateResponses(request, request.getResponse(), start, request.isHandled());
+        }
+        // else onCompletion will handle it.
+    }
+
+    private void updateResponses(HttpServletRequest request, HttpServletResponse response, long start, boolean isHandled) {
+        if (isHandled) {
+            mark(response.getStatus());
+        } else {
+            mark(404); // will end up with a 404 response sent by HttpChannel.handle
+        }
+        activeRequests.dec();
+        final long elapsedTime = System.currentTimeMillis() - start;
+        requests.update(elapsedTime, TimeUnit.MILLISECONDS);
+        requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
+    }
+
+    private void mark(int statusCode) {
+        if (DETAILED_METER_LEVELS.contains(responseMeteredLevel)) {
+            getResponseCodeMeter(statusCode).mark();
+        }
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            final int responseStatus = statusCode / 100;
+            if (responseStatus >= 1 && responseStatus <= 5) {
+                responses.get(responseStatus - 1).mark();
+            }
+        }
+    }
+
+    private Meter getResponseCodeMeter(int statusCode) {
+        return responseCodeMeters
+                .computeIfAbsent(statusCode, sc -> metricRegistry
+                        .meter(name(prefix, String.format("%d-responses", sc))));
+    }
+
+    private Timer requestTimer(String method) {
+        final HttpMethod m = HttpMethod.fromString(method);
+        if (m == null) {
+            return otherRequests;
+        } else {
+            switch (m) {
+                case GET:
+                    return getRequests;
+                case POST:
+                    return postRequests;
+                case PUT:
+                    return putRequests;
+                case HEAD:
+                    return headRequests;
+                case DELETE:
+                    return deleteRequests;
+                case OPTIONS:
+                    return optionsRequests;
+                case TRACE:
+                    return traceRequests;
+                case CONNECT:
+                    return connectRequests;
+                case MOVE:
+                    return moveRequests;
+                default:
+                    return otherRequests;
+            }
+        }
+    }
+
+    private class AsyncAttachingListener implements AsyncListener {
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+            event.getAsyncContext().addListener(new InstrumentedAsyncListener());
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {}
+    };
+
+    private class InstrumentedAsyncListener implements AsyncListener {
+        private final long startTime;
+
+        InstrumentedAsyncListener() {
+            this.startTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            asyncTimeouts.mark();
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
+            final HttpServletRequest request = (HttpServletRequest) state.getRequest();
+            final HttpServletResponse response = (HttpServletResponse) state.getResponse();
+            updateResponses(request, response, startTime, true);
+            if (!state.getHttpChannelState().isSuspended()) {
+                activeSuspended.dec();
+            }
+        }
+    }
+}
diff --git a/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedQueuedThreadPool.java b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedQueuedThreadPool.java
new file mode 100644
index 0000000..ac49f08
--- /dev/null
+++ b/metrics-jetty11/src/main/java/io/dropwizard/metrics/jetty11/InstrumentedQueuedThreadPool.java
@@ -0,0 +1,177 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import org.eclipse.jetty.util.annotation.Name;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ThreadFactory;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+public class InstrumentedQueuedThreadPool extends QueuedThreadPool {
+    private static final String NAME_UTILIZATION = "utilization";
+    private static final String NAME_UTILIZATION_MAX = "utilization-max";
+    private static final String NAME_SIZE = "size";
+    private static final String NAME_JOBS = "jobs";
+    private static final String NAME_JOBS_QUEUE_UTILIZATION = "jobs-queue-utilization";
+
+    private final MetricRegistry metricRegistry;
+    private String prefix;
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry) {
+        this(registry, 200);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads) {
+        this(registry, maxThreads, 8);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads) {
+        this(registry, maxThreads, minThreads, 60000);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue) {
+        this(registry, maxThreads, minThreads, 60000, queue);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout) {
+        this(registry, maxThreads, minThreads, idleTimeout, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue) {
+        this(registry, maxThreads, minThreads, idleTimeout, queue, (ThreadGroup) null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("prefix") String prefix) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, null, null, prefix);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadFactory") ThreadFactory threadFactory) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, null, threadFactory);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, threadGroup);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup) {
+        this(registry, maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup,
+                                        @Name("threadFactory") ThreadFactory threadFactory) {
+        this(registry, maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, threadFactory, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup,
+                                        @Name("threadFactory") ThreadFactory threadFactory,
+                                        @Name("prefix") String prefix) {
+        super(maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, threadFactory);
+        this.metricRegistry = registry;
+        this.prefix = prefix;
+    }
+
+    public String getPrefix() {
+        return prefix;
+    }
+
+    public void setPrefix(String prefix) {
+        this.prefix = prefix;
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.register(name(prefix, NAME_UTILIZATION), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(getThreads() - getIdleThreads(), getThreads());
+            }
+        });
+        metricRegistry.register(name(prefix, NAME_UTILIZATION_MAX), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(getThreads() - getIdleThreads(), getMaxThreads());
+            }
+        });
+        metricRegistry.registerGauge(name(prefix, NAME_SIZE), this::getThreads);
+        // This assumes the QueuedThreadPool is using a BlockingArrayQueue or
+        // ArrayBlockingQueue for its queue, and is therefore a constant-time operation.
+        metricRegistry.registerGauge(name(prefix, NAME_JOBS), () -> getQueue().size());
+        metricRegistry.register(name(prefix, NAME_JOBS_QUEUE_UTILIZATION), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                BlockingQueue<Runnable> queue = getQueue();
+                return Ratio.of(queue.size(), queue.size() + queue.remainingCapacity());
+            }
+        });
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION));
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION_MAX));
+        metricRegistry.remove(name(prefix, NAME_SIZE));
+        metricRegistry.remove(name(prefix, NAME_JOBS));
+        metricRegistry.remove(name(prefix, NAME_JOBS_QUEUE_UTILIZATION));
+
+        super.doStop();
+    }
+
+    private String getMetricPrefix() {
+        return this.prefix == null ? name(QueuedThreadPool.class, getName()) : name(this.prefix, getName());
+    }
+}
diff --git a/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedConnectionFactoryTest.java b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedConnectionFactoryTest.java
new file mode 100644
index 0000000..c12a77b
--- /dev/null
+++ b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedConnectionFactoryTest.java
@@ -0,0 +1,93 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedConnectionFactoryTest {
+    private final MetricRegistry registry = new MetricRegistry();
+    private final Server server = new Server();
+    private final ServerConnector connector =
+            new ServerConnector(server, new InstrumentedConnectionFactory(new HttpConnectionFactory(),
+                    registry.timer("http.connections"),
+                    registry.counter("http.active-connections")));
+    private final HttpClient client = new HttpClient();
+
+    @Before
+    public void setUp() throws Exception {
+        server.setHandler(new AbstractHandler() {
+            @Override
+            public void handle(String target,
+                               Request baseRequest,
+                               HttpServletRequest request,
+                               HttpServletResponse response) throws IOException, ServletException {
+                try (PrintWriter writer = response.getWriter()) {
+                    writer.println("OK");
+                }
+            }
+        });
+
+        server.addConnector(connector);
+        server.start();
+
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void instrumentsConnectionTimes() throws Exception {
+        final ContentResponse response = client.GET("http://localhost:" + connector.getLocalPort() + "/hello");
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        client.stop(); // close the connection
+
+        Thread.sleep(100); // make sure the connection is closed
+
+        final Timer timer = registry.timer(MetricRegistry.name("http.connections"));
+        assertThat(timer.getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void instrumentsActiveConnections() throws Exception {
+        final Counter counter = registry.counter("http.active-connections");
+
+        final ContentResponse response = client.GET("http://localhost:" + connector.getLocalPort() + "/hello");
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertThat(counter.getCount())
+                .isEqualTo(1);
+
+        client.stop(); // close the connection
+
+        Thread.sleep(100); // make sure the connection is closed
+
+        assertThat(counter.getCount())
+                .isEqualTo(0);
+    }
+}
diff --git a/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedHandlerTest.java b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedHandlerTest.java
new file mode 100644
index 0000000..ca18793
--- /dev/null
+++ b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedHandlerTest.java
@@ -0,0 +1,247 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+import jakarta.servlet.AsyncContext;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+public class InstrumentedHandlerTest {
+    private final HttpClient client = new HttpClient();
+    private final MetricRegistry registry = new MetricRegistry();
+    private final Server server = new Server();
+    private final ServerConnector connector = new ServerConnector(server);
+    private final InstrumentedHandler handler = new InstrumentedHandler(registry, null, ALL);
+
+    @Before
+    public void setUp() throws Exception {
+        handler.setName("handler");
+        handler.setHandler(new TestHandler());
+        server.addConnector(connector);
+        server.setHandler(handler);
+        server.start();
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void hasAName() throws Exception {
+        assertThat(handler.getName())
+                .isEqualTo("handler");
+    }
+
+    @Test
+    public void createsAndRemovesMetricsForTheHandler() throws Exception {
+        final ContentResponse response = client.GET(uri("/hello"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(404);
+
+        assertThat(registry.getNames())
+                .containsOnly(
+                        MetricRegistry.name(TestHandler.class, "handler.1xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.2xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.3xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.4xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.404-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.5xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-1m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-5m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-15m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-1m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-5m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-15m"),
+                        MetricRegistry.name(TestHandler.class, "handler.requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-suspended"),
+                        MetricRegistry.name(TestHandler.class, "handler.async-dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.async-timeouts"),
+                        MetricRegistry.name(TestHandler.class, "handler.get-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.put-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.trace-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.other-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.connect-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.head-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.post-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.options-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.delete-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.move-requests")
+                );
+
+        server.stop();
+
+        assertThat(registry.getNames())
+                .isEmpty();
+    }
+
+    @Test
+    public void responseTimesAreRecordedForBlockingResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/blocking"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertResponseTimesValid();
+    }
+
+    @Test
+    public void doStopDoesNotThrowNPE() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, null, ALL);
+        handler.setHandler(new TestHandler());
+
+        assertThatCode(handler::doStop).doesNotThrowAnyException();
+    }
+
+    @Test
+    public void gaugesAreRegisteredWithResponseMeteredLevelCoarse() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, "coarse", COARSE);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).containsKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    public void gaugesAreNotRegisteredWithResponseMeteredLevelDetailed() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, "detailed", DETAILED);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).doesNotContainKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    @Ignore("flaky on virtual machines")
+    public void responseTimesAreRecordedForAsyncResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/async"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertResponseTimesValid();
+    }
+
+    private void assertResponseTimesValid() {
+        assertThat(registry.getMeters().get(metricName() + ".2xx-responses")
+                .getCount()).isGreaterThan(0L);
+        assertThat(registry.getMeters().get(metricName() + ".200-responses")
+                .getCount()).isGreaterThan(0L);
+
+
+        assertThat(registry.getTimers().get(metricName() + ".get-requests")
+                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+
+        assertThat(registry.getTimers().get(metricName() + ".requests")
+                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+    }
+
+    private String uri(String path) {
+        return "http://localhost:" + connector.getLocalPort() + path;
+    }
+
+    private String metricName() {
+        return MetricRegistry.name(TestHandler.class.getName(), "handler");
+    }
+
+    /**
+     * test handler.
+     * <p>
+     * Supports
+     * <p>
+     * /blocking - uses the standard servlet api
+     * /async - uses the 3.1 async api to complete the request
+     * <p>
+     * all other requests will return 404
+     */
+    private static class TestHandler extends AbstractHandler {
+        @Override
+        public void handle(
+                String path,
+                Request request,
+                final HttpServletRequest httpServletRequest,
+                final HttpServletResponse httpServletResponse
+        ) throws IOException, ServletException {
+            switch (path) {
+                case "/blocking":
+                    request.setHandled(true);
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    httpServletResponse.setStatus(200);
+                    httpServletResponse.setContentType("text/plain");
+                    httpServletResponse.getWriter().write("some content from the blocking request\n");
+                    break;
+                case "/async":
+                    request.setHandled(true);
+                    final AsyncContext context = request.startAsync();
+                    Thread t = new Thread(() -> {
+                        try {
+                            Thread.sleep(100);
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                        }
+                        httpServletResponse.setStatus(200);
+                        httpServletResponse.setContentType("text/plain");
+                        final ServletOutputStream servletOutputStream;
+                        try {
+                            servletOutputStream = httpServletResponse.getOutputStream();
+                            servletOutputStream.setWriteListener(
+                                    new WriteListener() {
+                                        @Override
+                                        public void onWritePossible() throws IOException {
+                                            servletOutputStream.write("some content from the async\n"
+                                                    .getBytes(StandardCharsets.UTF_8));
+                                            context.complete();
+                                        }
+
+                                        @Override
+                                        public void onError(Throwable throwable) {
+                                            context.complete();
+                                        }
+                                    }
+                            );
+                        } catch (IOException e) {
+                            context.complete();
+                        }
+                    });
+                    t.start();
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+}
diff --git a/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedHttpChannelListenerTest.java b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedHttpChannelListenerTest.java
new file mode 100644
index 0000000..5badb80
--- /dev/null
+++ b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedHttpChannelListenerTest.java
@@ -0,0 +1,212 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import jakarta.servlet.AsyncContext;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedHttpChannelListenerTest {
+    private final HttpClient client = new HttpClient();
+    private final Server server = new Server();
+    private final ServerConnector connector = new ServerConnector(server);
+    private final TestHandler handler = new TestHandler();
+    private MetricRegistry registry;
+
+    @Before
+    public void setUp() throws Exception {
+        registry = new MetricRegistry();
+        connector.addBean(new InstrumentedHttpChannelListener(registry, MetricRegistry.name(TestHandler.class, "handler"), ALL));
+        server.addConnector(connector);
+        server.setHandler(handler);
+        server.start();
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void createsMetricsForTheHandler() throws Exception {
+        final ContentResponse response = client.GET(uri("/hello"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(404);
+
+        assertThat(registry.getNames())
+                .containsOnly(
+                        metricName("1xx-responses"),
+                        metricName("2xx-responses"),
+                        metricName("3xx-responses"),
+                        metricName("404-responses"),
+                        metricName("4xx-responses"),
+                        metricName("5xx-responses"),
+                        metricName("percent-4xx-1m"),
+                        metricName("percent-4xx-5m"),
+                        metricName("percent-4xx-15m"),
+                        metricName("percent-5xx-1m"),
+                        metricName("percent-5xx-5m"),
+                        metricName("percent-5xx-15m"),
+                        metricName("requests"),
+                        metricName("active-suspended"),
+                        metricName("async-dispatches"),
+                        metricName("async-timeouts"),
+                        metricName("get-requests"),
+                        metricName("put-requests"),
+                        metricName("active-dispatches"),
+                        metricName("trace-requests"),
+                        metricName("other-requests"),
+                        metricName("connect-requests"),
+                        metricName("dispatches"),
+                        metricName("head-requests"),
+                        metricName("post-requests"),
+                        metricName("options-requests"),
+                        metricName("active-requests"),
+                        metricName("delete-requests"),
+                        metricName("move-requests")
+                );
+    }
+
+
+    @Test
+    public void responseTimesAreRecordedForBlockingResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/blocking"));
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.getMediaType()).isEqualTo("text/plain");
+        assertThat(response.getContentAsString()).isEqualTo("some content from the blocking request");
+
+        assertResponseTimesValid();
+    }
+
+    @Test
+    public void responseTimesAreRecordedForAsyncResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/async"));
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.getMediaType()).isEqualTo("text/plain");
+        assertThat(response.getContentAsString()).isEqualTo("some content from the async");
+
+        assertResponseTimesValid();
+    }
+
+    private void assertResponseTimesValid() {
+        try {
+            Thread.sleep(100);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+
+        assertThat(registry.getMeters().get(metricName("2xx-responses"))
+                .getCount()).isPositive();
+        assertThat(registry.getMeters().get(metricName("200-responses"))
+                .getCount()).isPositive();
+
+        assertThat(registry.getTimers().get(metricName("get-requests"))
+                .getSnapshot().getMedian()).isPositive();
+
+        assertThat(registry.getTimers().get(metricName("requests"))
+                .getSnapshot().getMedian()).isPositive();
+    }
+
+    private String uri(String path) {
+        return "http://localhost:" + connector.getLocalPort() + path;
+    }
+
+    private String metricName(String metricName) {
+        return MetricRegistry.name(TestHandler.class.getName(), "handler", metricName);
+    }
+
+    /**
+     * test handler.
+     * <p>
+     * Supports
+     * <p>
+     * /blocking - uses the standard servlet api
+     * /async - uses the 3.1 async api to complete the request
+     * <p>
+     * all other requests will return 404
+     */
+    private static class TestHandler extends AbstractHandler {
+        @Override
+        public void handle(
+                String path,
+                Request request,
+                final HttpServletRequest httpServletRequest,
+                final HttpServletResponse httpServletResponse) throws IOException {
+            switch (path) {
+                case "/blocking":
+                    request.setHandled(true);
+                    httpServletResponse.setStatus(200);
+                    httpServletResponse.setContentType("text/plain");
+                    httpServletResponse.getWriter().write("some content from the blocking request");
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        httpServletResponse.setStatus(500);
+                        Thread.currentThread().interrupt();
+                    }
+                    break;
+                case "/async":
+                    request.setHandled(true);
+                    final AsyncContext context = request.startAsync();
+                    Thread t = new Thread(() -> {
+                        httpServletResponse.setStatus(200);
+                        httpServletResponse.setContentType("text/plain");
+                        try {
+                            Thread.sleep(100);
+                        } catch (InterruptedException e) {
+                            httpServletResponse.setStatus(500);
+                            Thread.currentThread().interrupt();
+                        }
+                        final ServletOutputStream servletOutputStream;
+                        try {
+                            servletOutputStream = httpServletResponse.getOutputStream();
+                            servletOutputStream.setWriteListener(
+                                    new WriteListener() {
+                                        @Override
+                                        public void onWritePossible() throws IOException {
+                                            servletOutputStream.write("some content from the async"
+                                                    .getBytes(StandardCharsets.UTF_8));
+                                            context.complete();
+                                        }
+
+                                        @Override
+                                        public void onError(Throwable throwable) {
+                                            context.complete();
+                                        }
+                                    }
+                            );
+                        } catch (IOException e) {
+                            context.complete();
+                        }
+                    });
+                    t.start();
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+}
diff --git a/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedQueuedThreadPoolTest.java b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedQueuedThreadPoolTest.java
new file mode 100644
index 0000000..e373239
--- /dev/null
+++ b/metrics-jetty11/src/test/java/io/dropwizard/metrics/jetty11/InstrumentedQueuedThreadPoolTest.java
@@ -0,0 +1,49 @@
+package io.dropwizard.metrics.jetty11;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedQueuedThreadPoolTest {
+    private static final String PREFIX = "prefix";
+
+    private MetricRegistry metricRegistry;
+    private InstrumentedQueuedThreadPool iqtp;
+
+    @Before
+    public void setUp() {
+        metricRegistry = new MetricRegistry();
+        iqtp = new InstrumentedQueuedThreadPool(metricRegistry);
+    }
+
+    @Test
+    public void customMetricsPrefix() throws Exception {
+        iqtp.setPrefix(PREFIX);
+        iqtp.start();
+
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("Custom metrics prefix doesn't match")
+                .allSatisfy(name -> assertThat(name).startsWith(PREFIX));
+
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
+    }
+
+    @Test
+    public void metricsPrefixBackwardCompatible() throws Exception {
+        iqtp.start();
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .allSatisfy(name -> assertThat(name).startsWith(QueuedThreadPool.class.getName()));
+
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
+    }
+}
diff --git a/metrics-jetty12-ee10/pom.xml b/metrics-jetty12-ee10/pom.xml
new file mode 100644
index 0000000..372023a
--- /dev/null
+++ b/metrics-jetty12-ee10/pom.xml
@@ -0,0 +1,138 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jetty12-ee10</artifactId>
+    <name>Metrics Integration for Jetty 12.x and higher with Jakarta EE 10</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of extensions for Jetty 12.x and higher which provide instrumentation of thread pools, connector
+        metrics, and application latency and utilization. This module uses the Servlet API from Jakarta EE 10.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.jetty12.ee10</javaModuleName>
+
+        <maven.compiler.release>17</maven.compiler.release>
+
+        <slf4j.version>2.0.11</slf4j.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-bom</artifactId>
+                <version>${jetty12.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty.ee10</groupId>
+                <artifactId>jetty-ee10-bom</artifactId>
+                <version>${jetty12.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.slf4j</groupId>
+                <artifactId>slf4j-api</artifactId>
+                <version>${slf4j.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>jakarta.servlet</groupId>
+                <artifactId>jakarta.servlet-api</artifactId>
+                <version>${servlet6.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-jetty12</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-util</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty.ee10</groupId>
+            <artifactId>jetty-ee10-servlet</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.servlet</groupId>
+            <artifactId>jakarta.servlet-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-client</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jetty12-ee10/src/main/java/io/dropwizard/metrics/jetty12/ee10/InstrumentedEE10Handler.java b/metrics-jetty12-ee10/src/main/java/io/dropwizard/metrics/jetty12/ee10/InstrumentedEE10Handler.java
new file mode 100644
index 0000000..8a65e82
--- /dev/null
+++ b/metrics-jetty12-ee10/src/main/java/io/dropwizard/metrics/jetty12/ee10/InstrumentedEE10Handler.java
@@ -0,0 +1,176 @@
+package io.dropwizard.metrics.jetty12.ee10;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import io.dropwizard.metrics.jetty12.AbstractInstrumentedHandler;
+import jakarta.servlet.AsyncEvent;
+import jakarta.servlet.AsyncListener;
+import org.eclipse.jetty.ee10.servlet.AsyncContextState;
+import org.eclipse.jetty.ee10.servlet.ServletApiRequest;
+import org.eclipse.jetty.ee10.servlet.ServletApiResponse;
+import org.eclipse.jetty.ee10.servlet.ServletChannelState;
+import org.eclipse.jetty.ee10.servlet.ServletContextRequest;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.util.Callback;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+
+/**
+ * A Jetty {@link Handler} which records various metrics about an underlying {@link Handler}
+ * instance. This {@link Handler} requires a {@link org.eclipse.jetty.ee10.servlet.ServletContextHandler} to be present.
+ * For correct behaviour, the {@link org.eclipse.jetty.ee10.servlet.ServletContextHandler} should be before this handler
+ * in the handler chain. To achieve this, one can use
+ * {@link org.eclipse.jetty.ee10.servlet.ServletContextHandler#insertHandler(Singleton)}.
+ */
+public class InstrumentedEE10Handler extends AbstractInstrumentedHandler {
+    private AsyncListener listener;
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     */
+    public InstrumentedEE10Handler(MetricRegistry registry) {
+        super(registry, null);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     */
+    public InstrumentedEE10Handler(MetricRegistry registry, String prefix) {
+        super(registry, prefix, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    public InstrumentedEE10Handler(MetricRegistry registry, String prefix, ResponseMeteredLevel responseMeteredLevel) {
+        super(registry, prefix, responseMeteredLevel);
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        this.listener = new AsyncAttachingListener();
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        super.doStop();
+    }
+
+    @Override
+    public boolean handle(Request request, Response response, Callback callback) throws Exception {
+        ServletContextRequest servletContextRequest = Request.as(request, ServletContextRequest.class);
+
+        // only handle servlet requests with the InstrumentedHandler
+        // because it depends on the ServletRequestState
+        if (servletContextRequest == null) {
+            return super.handle(request, response, callback);
+        }
+
+        activeDispatches.inc();
+
+        final long start;
+        final ServletChannelState state = servletContextRequest.getServletRequestState();
+        if (state.isInitial()) {
+            // new request
+            activeRequests.inc();
+            start = Request.getTimeStamp(request);
+            state.addListener(listener);
+        } else {
+            // resumed request
+            start = System.currentTimeMillis();
+            activeSuspended.dec();
+            if (state.getState() == ServletChannelState.State.HANDLING) {
+                asyncDispatches.mark();
+            }
+        }
+
+        boolean handled = false;
+
+        try {
+            handled = super.handle(request, response, callback);
+        } finally {
+            final long now = System.currentTimeMillis();
+            final long dispatched = now - start;
+
+            activeDispatches.dec();
+            dispatches.update(dispatched, TimeUnit.MILLISECONDS);
+
+            if (state.isSuspended()) {
+                activeSuspended.inc();
+            } else if (state.isInitial()) {
+                updateResponses(request, response, start, handled);
+            }
+            // else onCompletion will handle it.
+        }
+
+        return handled;
+    }
+
+    private class AsyncAttachingListener implements AsyncListener {
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+            event.getAsyncContext().addListener(new InstrumentedAsyncListener());
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {}
+    }
+
+    private class InstrumentedAsyncListener implements AsyncListener {
+        private final long startTime;
+
+        InstrumentedAsyncListener() {
+            this.startTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            asyncTimeouts.mark();
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
+            final ServletApiRequest request = (ServletApiRequest) state.getRequest();
+            final ServletApiResponse response = (ServletApiResponse) state.getResponse();
+            updateResponses(request.getRequest(), response.getResponse(), startTime, true);
+
+            final ServletContextRequest servletContextRequest = Request.as(request.getRequest(), ServletContextRequest.class);
+            final ServletChannelState servletRequestState = servletContextRequest.getServletRequestState();
+            if (!servletRequestState.isSuspended()) {
+                activeSuspended.dec();
+            }
+        }
+    }
+}
diff --git a/metrics-jetty12-ee10/src/test/java/io/dropwizard/metrics/jetty12/ee10/InstrumentedEE10HandlerTest.java b/metrics-jetty12-ee10/src/test/java/io/dropwizard/metrics/jetty12/ee10/InstrumentedEE10HandlerTest.java
new file mode 100644
index 0000000..e2c56ac
--- /dev/null
+++ b/metrics-jetty12-ee10/src/test/java/io/dropwizard/metrics/jetty12/ee10/InstrumentedEE10HandlerTest.java
@@ -0,0 +1,272 @@
+package io.dropwizard.metrics.jetty12.ee10;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.ee10.servlet.DefaultServlet;
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee10.servlet.ServletContextRequest;
+import org.eclipse.jetty.ee10.servlet.ServletHandler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.util.Callback;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+import jakarta.servlet.AsyncContext;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+public class InstrumentedEE10HandlerTest {
+    private final HttpClient client = new HttpClient();
+    private final MetricRegistry registry = new MetricRegistry();
+    private final Server server = new Server();
+    private final ServerConnector connector = new ServerConnector(server);
+    private final InstrumentedEE10Handler handler = new InstrumentedEE10Handler(registry, null, ALL);
+
+    @Before
+    public void setUp() throws Exception {
+        handler.setName("handler");
+
+        TestHandler testHandler = new TestHandler();
+        // a servlet handler needs a servlet mapping, else the request will be short-circuited
+        // so use the DefaultServlet here
+        testHandler.addServletWithMapping(DefaultServlet.class, "/");
+
+        // builds the following handler chain:
+        // ServletContextHandler -> InstrumentedHandler -> TestHandler
+        // the ServletContextHandler is needed to utilize servlet related classes
+        ServletContextHandler servletContextHandler = new ServletContextHandler();
+        servletContextHandler.setHandler(testHandler);
+        servletContextHandler.insertHandler(handler);
+        server.setHandler(servletContextHandler);
+
+        server.addConnector(connector);
+        server.start();
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void hasAName() throws Exception {
+        assertThat(handler.getName())
+                .isEqualTo("handler");
+    }
+
+    @Test
+    public void createsAndRemovesMetricsForTheHandler() throws Exception {
+        final ContentResponse response = client.GET(uri("/hello"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(404);
+
+        assertThat(registry.getNames())
+                .containsOnly(
+                        MetricRegistry.name(TestHandler.class, "handler.1xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.2xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.3xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.4xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.404-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.5xx-responses"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-1m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-5m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-4xx-15m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-1m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-5m"),
+                        MetricRegistry.name(TestHandler.class, "handler.percent-5xx-15m"),
+                        MetricRegistry.name(TestHandler.class, "handler.requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-suspended"),
+                        MetricRegistry.name(TestHandler.class, "handler.async-dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.async-timeouts"),
+                        MetricRegistry.name(TestHandler.class, "handler.get-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.put-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.trace-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.other-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.connect-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.dispatches"),
+                        MetricRegistry.name(TestHandler.class, "handler.head-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.post-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.options-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.active-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.delete-requests"),
+                        MetricRegistry.name(TestHandler.class, "handler.move-requests")
+                );
+
+        server.stop();
+
+        assertThat(registry.getNames())
+                .isEmpty();
+    }
+
+    @Test
+    @Ignore("flaky on virtual machines")
+    public void responseTimesAreRecordedForBlockingResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/blocking"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertResponseTimesValid();
+    }
+
+    @Test
+    public void doStopDoesNotThrowNPE() throws Exception {
+        InstrumentedEE10Handler handler = new InstrumentedEE10Handler(registry, null, ALL);
+        handler.setHandler(new TestHandler());
+
+        assertThatCode(handler::doStop).doesNotThrowAnyException();
+    }
+
+    @Test
+    public void gaugesAreRegisteredWithResponseMeteredLevelCoarse() throws Exception {
+        InstrumentedEE10Handler handler = new InstrumentedEE10Handler(registry, "coarse", COARSE);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).containsKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    public void gaugesAreNotRegisteredWithResponseMeteredLevelDetailed() throws Exception {
+        InstrumentedEE10Handler handler = new InstrumentedEE10Handler(registry, "detailed", DETAILED);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).doesNotContainKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    @Ignore("flaky on virtual machines")
+    public void responseTimesAreRecordedForAsyncResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/async"));
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertResponseTimesValid();
+    }
+
+    private void assertResponseTimesValid() {
+        assertThat(registry.getMeters().get(metricName() + ".2xx-responses")
+                .getCount()).isGreaterThan(0L);
+        assertThat(registry.getMeters().get(metricName() + ".200-responses")
+                .getCount()).isGreaterThan(0L);
+
+
+        assertThat(registry.getTimers().get(metricName() + ".get-requests")
+                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+
+        assertThat(registry.getTimers().get(metricName() + ".requests")
+                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+    }
+
+    private String uri(String path) {
+        return "http://localhost:" + connector.getLocalPort() + path;
+    }
+
+    private String metricName() {
+        return MetricRegistry.name(TestHandler.class.getName(), "handler");
+    }
+
+    /**
+     * test handler.
+     * <p>
+     * Supports
+     * <p>
+     * /blocking - uses the standard servlet api
+     * /async - uses the 3.1 async api to complete the request
+     * <p>
+     * all other requests will return 404
+     */
+    private static class TestHandler extends ServletHandler {
+        @Override
+        public boolean handle(Request request, Response response, Callback callback) throws Exception {
+            ServletContextRequest servletContextRequest = Request.as(request, ServletContextRequest.class);
+            if (servletContextRequest == null) {
+                return false;
+            }
+
+            HttpServletRequest httpServletRequest = servletContextRequest.getServletApiRequest();
+            HttpServletResponse httpServletResponse = servletContextRequest.getHttpServletResponse();
+
+            String path = request.getHttpURI().getPath();
+            switch (path) {
+                case "/blocking":
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    httpServletResponse.setStatus(200);
+                    httpServletResponse.setContentType("text/plain");
+                    httpServletResponse.getWriter().write("some content from the blocking request\n");
+                    callback.succeeded();
+                    return true;
+                case "/async":
+                    servletContextRequest.getState().handling();
+                    final AsyncContext context = httpServletRequest.startAsync();
+                    Thread t = new Thread(() -> {
+                        try {
+                            Thread.sleep(100);
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                        }
+                        httpServletResponse.setStatus(200);
+                        httpServletResponse.setContentType("text/plain");
+                        final ServletOutputStream servletOutputStream;
+                        try {
+                            servletOutputStream = httpServletResponse.getOutputStream();
+                            servletOutputStream.setWriteListener(
+                                    new WriteListener() {
+                                        @Override
+                                        public void onWritePossible() throws IOException {
+                                            servletOutputStream.write("some content from the async\n"
+                                                    .getBytes(StandardCharsets.UTF_8));
+                                            context.complete();
+                                            servletContextRequest.getServletChannel().handle();
+                                        }
+
+                                        @Override
+                                        public void onError(Throwable throwable) {
+                                            context.complete();
+                                            servletContextRequest.getServletChannel().handle();
+                                        }
+                                    }
+                            );
+                            servletContextRequest.getHttpOutput().run();
+                        } catch (IOException e) {
+                            context.complete();
+                            servletContextRequest.getServletChannel().handle();
+                        }
+                    });
+                    t.start();
+                    return true;
+                default:
+                    return false;
+            }
+        }
+    }
+}
diff --git a/metrics-jetty12/pom.xml b/metrics-jetty12/pom.xml
new file mode 100644
index 0000000..396c48e
--- /dev/null
+++ b/metrics-jetty12/pom.xml
@@ -0,0 +1,117 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-jetty12</artifactId>
+    <name>Metrics Integration for Jetty 12.x and higher</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of extensions for Jetty 12.x and higher which provide instrumentation of thread pools, connector
+        metrics, and application latency and utilization.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.jetty12</javaModuleName>
+
+        <maven.compiler.release>17</maven.compiler.release>
+
+        <slf4j.version>2.0.11</slf4j.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-bom</artifactId>
+                <version>${jetty12.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.slf4j</groupId>
+                <artifactId>slf4j-api</artifactId>
+                <version>${slf4j.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-server</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-io</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-util</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-client</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/AbstractInstrumentedHandler.java b/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/AbstractInstrumentedHandler.java
new file mode 100644
index 0000000..d2a7899
--- /dev/null
+++ b/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/AbstractInstrumentedHandler.java
@@ -0,0 +1,340 @@
+package io.dropwizard.metrics.jetty12;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+/**
+ * An abstract base class of a Jetty {@link Handler} which records various metrics about an underlying {@link Handler}
+ * instance.
+ */
+public abstract class AbstractInstrumentedHandler extends Handler.Wrapper {
+    protected static final String NAME_REQUESTS = "requests";
+    protected static final String NAME_DISPATCHES = "dispatches";
+    protected static final String NAME_ACTIVE_REQUESTS = "active-requests";
+    protected static final String NAME_ACTIVE_DISPATCHES = "active-dispatches";
+    protected static final String NAME_ACTIVE_SUSPENDED = "active-suspended";
+    protected static final String NAME_ASYNC_DISPATCHES = "async-dispatches";
+    protected static final String NAME_ASYNC_TIMEOUTS = "async-timeouts";
+    protected static final String NAME_1XX_RESPONSES = "1xx-responses";
+    protected static final String NAME_2XX_RESPONSES = "2xx-responses";
+    protected static final String NAME_3XX_RESPONSES = "3xx-responses";
+    protected static final String NAME_4XX_RESPONSES = "4xx-responses";
+    protected static final String NAME_5XX_RESPONSES = "5xx-responses";
+    protected static final String NAME_GET_REQUESTS = "get-requests";
+    protected static final String NAME_POST_REQUESTS = "post-requests";
+    protected static final String NAME_HEAD_REQUESTS = "head-requests";
+    protected static final String NAME_PUT_REQUESTS = "put-requests";
+    protected static final String NAME_DELETE_REQUESTS = "delete-requests";
+    protected static final String NAME_OPTIONS_REQUESTS = "options-requests";
+    protected static final String NAME_TRACE_REQUESTS = "trace-requests";
+    protected static final String NAME_CONNECT_REQUESTS = "connect-requests";
+    protected static final String NAME_MOVE_REQUESTS = "move-requests";
+    protected static final String NAME_OTHER_REQUESTS = "other-requests";
+    protected static final String NAME_PERCENT_4XX_1M = "percent-4xx-1m";
+    protected static final String NAME_PERCENT_4XX_5M = "percent-4xx-5m";
+    protected static final String NAME_PERCENT_4XX_15M = "percent-4xx-15m";
+    protected static final String NAME_PERCENT_5XX_1M = "percent-5xx-1m";
+    protected static final String NAME_PERCENT_5XX_5M = "percent-5xx-5m";
+    protected static final String NAME_PERCENT_5XX_15M = "percent-5xx-15m";
+    protected static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+    protected static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+
+    protected final MetricRegistry metricRegistry;
+
+    private String name;
+    protected final String prefix;
+
+    // the requests handled by this handler, excluding active
+    protected Timer requests;
+
+    // the number of dispatches seen by this handler, excluding active
+    protected Timer dispatches;
+
+    // the number of active requests
+    protected Counter activeRequests;
+
+    // the number of active dispatches
+    protected Counter activeDispatches;
+
+    // the number of requests currently suspended.
+    protected Counter activeSuspended;
+
+    // the number of requests that have been asynchronously dispatched
+    protected Meter asyncDispatches;
+
+    // the number of requests that expired while suspended
+    protected Meter asyncTimeouts;
+
+    protected final ResponseMeteredLevel responseMeteredLevel;
+    protected List<Meter> responses;
+    protected Map<Integer, Meter> responseCodeMeters;
+
+    protected Timer getRequests;
+    protected Timer postRequests;
+    protected Timer headRequests;
+    protected Timer putRequests;
+    protected Timer deleteRequests;
+    protected Timer optionsRequests;
+    protected Timer traceRequests;
+    protected Timer connectRequests;
+    protected Timer moveRequests;
+    protected Timer otherRequests;
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     */
+    protected AbstractInstrumentedHandler(MetricRegistry registry) {
+        this(registry, null);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     */
+    protected AbstractInstrumentedHandler(MetricRegistry registry, String prefix) {
+        this(registry, prefix, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    protected AbstractInstrumentedHandler(MetricRegistry registry, String prefix, ResponseMeteredLevel responseMeteredLevel) {
+        this.responseMeteredLevel = responseMeteredLevel;
+        this.metricRegistry = registry;
+        this.prefix = prefix;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        final String prefix = getMetricPrefix();
+
+        this.requests = metricRegistry.timer(name(prefix, NAME_REQUESTS));
+        this.dispatches = metricRegistry.timer(name(prefix, NAME_DISPATCHES));
+
+        this.activeRequests = metricRegistry.counter(name(prefix, NAME_ACTIVE_REQUESTS));
+        this.activeDispatches = metricRegistry.counter(name(prefix, NAME_ACTIVE_DISPATCHES));
+        this.activeSuspended = metricRegistry.counter(name(prefix, NAME_ACTIVE_SUSPENDED));
+
+        this.asyncDispatches = metricRegistry.meter(name(prefix, NAME_ASYNC_DISPATCHES));
+        this.asyncTimeouts = metricRegistry.meter(name(prefix, NAME_ASYNC_TIMEOUTS));
+
+        this.responseCodeMeters = DETAILED_METER_LEVELS.contains(responseMeteredLevel) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+
+        this.getRequests = metricRegistry.timer(name(prefix, NAME_GET_REQUESTS));
+        this.postRequests = metricRegistry.timer(name(prefix, NAME_POST_REQUESTS));
+        this.headRequests = metricRegistry.timer(name(prefix, NAME_HEAD_REQUESTS));
+        this.putRequests = metricRegistry.timer(name(prefix, NAME_PUT_REQUESTS));
+        this.deleteRequests = metricRegistry.timer(name(prefix, NAME_DELETE_REQUESTS));
+        this.optionsRequests = metricRegistry.timer(name(prefix, NAME_OPTIONS_REQUESTS));
+        this.traceRequests = metricRegistry.timer(name(prefix, NAME_TRACE_REQUESTS));
+        this.connectRequests = metricRegistry.timer(name(prefix, NAME_CONNECT_REQUESTS));
+        this.moveRequests = metricRegistry.timer(name(prefix, NAME_MOVE_REQUESTS));
+        this.otherRequests = metricRegistry.timer(name(prefix, NAME_OTHER_REQUESTS));
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            this.responses = Collections.unmodifiableList(Arrays.asList(
+                            metricRegistry.meter(name(prefix, NAME_1XX_RESPONSES)), // 1xx
+                            metricRegistry.meter(name(prefix, NAME_2XX_RESPONSES)), // 2xx
+                            metricRegistry.meter(name(prefix, NAME_3XX_RESPONSES)), // 3xx
+                            metricRegistry.meter(name(prefix, NAME_4XX_RESPONSES)), // 4xx
+                            metricRegistry.meter(name(prefix, NAME_5XX_RESPONSES))  // 5xx
+                    ));
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_15M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_15M), new RatioGauge() {
+                @Override
+                public Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
+        } else {
+             this.responses = Collections.emptyList();
+        }
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_SUSPENDED));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_TIMEOUTS));
+        metricRegistry.remove(name(prefix, NAME_1XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_2XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_3XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_4XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_5XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_GET_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_POST_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_HEAD_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PUT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DELETE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OPTIONS_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_TRACE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_CONNECT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_MOVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OTHER_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_15M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_15M));
+
+        if (responseCodeMeters != null) {
+            responseCodeMeters.keySet().stream()
+                    .map(sc -> name(getMetricPrefix(), String.format("%d-responses", sc)))
+                    .forEach(metricRegistry::remove);
+        }
+        super.doStop();
+    }
+
+    protected Timer requestTimer(String method) {
+        final HttpMethod m = HttpMethod.fromString(method);
+        if (m == null) {
+            return otherRequests;
+        } else {
+            switch (m) {
+                case GET:
+                    return getRequests;
+                case POST:
+                    return postRequests;
+                case PUT:
+                    return putRequests;
+                case HEAD:
+                    return headRequests;
+                case DELETE:
+                    return deleteRequests;
+                case OPTIONS:
+                    return optionsRequests;
+                case TRACE:
+                    return traceRequests;
+                case CONNECT:
+                    return connectRequests;
+                case MOVE:
+                    return moveRequests;
+                default:
+                    return otherRequests;
+            }
+        }
+    }
+
+    protected void updateResponses(Request request, Response response, long start, boolean isHandled) {
+        if (isHandled) {
+            mark(response.getStatus());
+        } else {
+            mark(404);; // will end up with a 404 response sent by HttpChannel.handle
+        }
+        activeRequests.dec();
+        final long elapsedTime = System.currentTimeMillis() - start;
+        requests.update(elapsedTime, TimeUnit.MILLISECONDS);
+        requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
+    }
+
+    protected void mark(int statusCode) {
+        if (DETAILED_METER_LEVELS.contains(responseMeteredLevel)) {
+            getResponseCodeMeter(statusCode).mark();
+        }
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            final int responseStatus = statusCode / 100;
+            if (responseStatus >= 1 && responseStatus <= 5) {
+                responses.get(responseStatus - 1).mark();
+            }
+        }
+    }
+
+    protected Meter getResponseCodeMeter(int statusCode) {
+        return responseCodeMeters
+                .computeIfAbsent(statusCode, sc -> metricRegistry
+                        .meter(name(getMetricPrefix(), String.format("%d-responses", sc))));
+    }
+
+    protected String getMetricPrefix() {
+        return this.prefix == null ? name(getHandler().getClass(), name) : name(this.prefix, name);
+    }
+}
diff --git a/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/InstrumentedConnectionFactory.java b/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/InstrumentedConnectionFactory.java
new file mode 100644
index 0000000..679d310
--- /dev/null
+++ b/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/InstrumentedConnectionFactory.java
@@ -0,0 +1,63 @@
+package io.dropwizard.metrics.jetty12;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Timer;
+import org.eclipse.jetty.io.Connection;
+import org.eclipse.jetty.io.EndPoint;
+import org.eclipse.jetty.server.ConnectionFactory;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.util.component.ContainerLifeCycle;
+
+import java.util.List;
+
+public class InstrumentedConnectionFactory extends ContainerLifeCycle implements ConnectionFactory {
+    private final ConnectionFactory connectionFactory;
+    private final Timer timer;
+    private final Counter counter;
+
+    public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer) {
+        this(connectionFactory, timer, null);
+    }
+
+    public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer, Counter counter) {
+        this.connectionFactory = connectionFactory;
+        this.timer = timer;
+        this.counter = counter;
+        addBean(connectionFactory);
+    }
+
+    @Override
+    public String getProtocol() {
+        return connectionFactory.getProtocol();
+    }
+
+    @Override
+    public List<String> getProtocols() {
+        return connectionFactory.getProtocols();
+    }
+
+    @Override
+    public Connection newConnection(Connector connector, EndPoint endPoint) {
+        final Connection connection = connectionFactory.newConnection(connector, endPoint);
+        connection.addEventListener(new Connection.Listener() {
+            private Timer.Context context;
+
+            @Override
+            public void onOpened(Connection connection) {
+                this.context = timer.time();
+                if (counter != null) {
+                    counter.inc();
+                }
+            }
+
+            @Override
+            public void onClosed(Connection connection) {
+                context.stop();
+                if (counter != null) {
+                    counter.dec();
+                }
+            }
+        });
+        return connection;
+    }
+}
diff --git a/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/InstrumentedQueuedThreadPool.java b/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/InstrumentedQueuedThreadPool.java
new file mode 100644
index 0000000..9911737
--- /dev/null
+++ b/metrics-jetty12/src/main/java/io/dropwizard/metrics/jetty12/InstrumentedQueuedThreadPool.java
@@ -0,0 +1,168 @@
+package io.dropwizard.metrics.jetty12;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import org.eclipse.jetty.util.annotation.Name;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ThreadFactory;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+public class InstrumentedQueuedThreadPool extends QueuedThreadPool {
+    private static final String NAME_UTILIZATION = "utilization";
+    private static final String NAME_UTILIZATION_MAX = "utilization-max";
+    private static final String NAME_SIZE = "size";
+    private static final String NAME_JOBS = "jobs";
+    private static final String NAME_JOBS_QUEUE_UTILIZATION = "jobs-queue-utilization";
+
+    private final MetricRegistry metricRegistry;
+    private String prefix;
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry) {
+        this(registry, 200);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads) {
+        this(registry, maxThreads, 8);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads) {
+        this(registry, maxThreads, minThreads, 60000);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue) {
+        this(registry, maxThreads, minThreads, 60000, queue);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout) {
+        this(registry, maxThreads, minThreads, idleTimeout, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue) {
+        this(registry, maxThreads, minThreads, idleTimeout, queue, (ThreadGroup) null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadFactory") ThreadFactory threadFactory) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, null, threadFactory);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup) {
+        this(registry, maxThreads, minThreads, idleTimeout, -1, queue, threadGroup);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup) {
+        this(registry, maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup,
+                                        @Name("threadFactory") ThreadFactory threadFactory) {
+        this(registry, maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, threadFactory, null);
+    }
+
+    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
+                                        @Name("maxThreads") int maxThreads,
+                                        @Name("minThreads") int minThreads,
+                                        @Name("idleTimeout") int idleTimeout,
+                                        @Name("reservedThreads") int reservedThreads,
+                                        @Name("queue") BlockingQueue<Runnable> queue,
+                                        @Name("threadGroup") ThreadGroup threadGroup,
+                                        @Name("threadFactory") ThreadFactory threadFactory,
+                                        @Name("prefix") String prefix) {
+        super(maxThreads, minThreads, idleTimeout, reservedThreads, queue, threadGroup, threadFactory);
+        this.metricRegistry = registry;
+        this.prefix = prefix;
+    }
+
+    public String getPrefix() {
+        return prefix;
+    }
+
+    public void setPrefix(String prefix) {
+        this.prefix = prefix;
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.register(name(prefix, NAME_UTILIZATION), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(getThreads() - getIdleThreads(), getThreads());
+            }
+        });
+        metricRegistry.register(name(prefix, NAME_UTILIZATION_MAX), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(getThreads() - getIdleThreads(), getMaxThreads());
+            }
+        });
+        metricRegistry.registerGauge(name(prefix, NAME_SIZE), this::getThreads);
+        // This assumes the QueuedThreadPool is using a BlockingArrayQueue or
+        // ArrayBlockingQueue for its queue, and is therefore a constant-time operation.
+        metricRegistry.registerGauge(name(prefix, NAME_JOBS), () -> getQueue().size());
+        metricRegistry.register(name(prefix, NAME_JOBS_QUEUE_UTILIZATION), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                BlockingQueue<Runnable> queue = getQueue();
+                return Ratio.of(queue.size(), queue.size() + queue.remainingCapacity());
+            }
+        });
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION));
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION_MAX));
+        metricRegistry.remove(name(prefix, NAME_SIZE));
+        metricRegistry.remove(name(prefix, NAME_JOBS));
+        metricRegistry.remove(name(prefix, NAME_JOBS_QUEUE_UTILIZATION));
+
+        super.doStop();
+    }
+
+    private String getMetricPrefix() {
+        return this.prefix == null ? name(QueuedThreadPool.class, getName()) : name(this.prefix, getName());
+    }
+}
diff --git a/metrics-jetty12/src/test/java/io/dropwizard/metrics/jetty12/InstrumentedConnectionFactoryTest.java b/metrics-jetty12/src/test/java/io/dropwizard/metrics/jetty12/InstrumentedConnectionFactoryTest.java
new file mode 100644
index 0000000..a988de2
--- /dev/null
+++ b/metrics-jetty12/src/test/java/io/dropwizard/metrics/jetty12/InstrumentedConnectionFactoryTest.java
@@ -0,0 +1,86 @@
+package io.dropwizard.metrics.jetty12;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.io.Content;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.util.Callback;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedConnectionFactoryTest {
+    private final MetricRegistry registry = new MetricRegistry();
+    private final Server server = new Server();
+    private final ServerConnector connector =
+            new ServerConnector(server, new InstrumentedConnectionFactory(new HttpConnectionFactory(),
+                    registry.timer("http.connections"),
+                    registry.counter("http.active-connections")));
+    private final HttpClient client = new HttpClient();
+
+    @Before
+    public void setUp() throws Exception {
+        server.setHandler(new Handler.Abstract() {
+            @Override
+            public boolean handle(Request request, Response response, Callback callback) throws Exception {
+                Content.Sink.write(response, true, "OK", callback);
+                return true;
+            }
+        });
+
+        server.addConnector(connector);
+        server.start();
+
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void instrumentsConnectionTimes() throws Exception {
+        final ContentResponse response = client.GET("http://localhost:" + connector.getLocalPort() + "/hello");
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        client.stop(); // close the connection
+
+        Thread.sleep(100); // make sure the connection is closed
+
+        final Timer timer = registry.timer(MetricRegistry.name("http.connections"));
+        assertThat(timer.getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void instrumentsActiveConnections() throws Exception {
+        final Counter counter = registry.counter("http.active-connections");
+
+        final ContentResponse response = client.GET("http://localhost:" + connector.getLocalPort() + "/hello");
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertThat(counter.getCount())
+                .isEqualTo(1);
+
+        client.stop(); // close the connection
+
+        Thread.sleep(100); // make sure the connection is closed
+
+        assertThat(counter.getCount())
+                .isEqualTo(0);
+    }
+}
diff --git a/metrics-jetty12/src/test/java/io/dropwizard/metrics/jetty12/InstrumentedQueuedThreadPoolTest.java b/metrics-jetty12/src/test/java/io/dropwizard/metrics/jetty12/InstrumentedQueuedThreadPoolTest.java
new file mode 100644
index 0000000..5a4e4af
--- /dev/null
+++ b/metrics-jetty12/src/test/java/io/dropwizard/metrics/jetty12/InstrumentedQueuedThreadPoolTest.java
@@ -0,0 +1,49 @@
+package io.dropwizard.metrics.jetty12;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedQueuedThreadPoolTest {
+    private static final String PREFIX = "prefix";
+
+    private MetricRegistry metricRegistry;
+    private InstrumentedQueuedThreadPool iqtp;
+
+    @Before
+    public void setUp() {
+        metricRegistry = new MetricRegistry();
+        iqtp = new InstrumentedQueuedThreadPool(metricRegistry);
+    }
+
+    @Test
+    public void customMetricsPrefix() throws Exception {
+        iqtp.setPrefix(PREFIX);
+        iqtp.start();
+
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("Custom metrics prefix doesn't match")
+                .allSatisfy(name -> assertThat(name).startsWith(PREFIX));
+
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
+    }
+
+    @Test
+    public void metricsPrefixBackwardCompatible() throws Exception {
+        iqtp.start();
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .allSatisfy(name -> assertThat(name).startsWith(QueuedThreadPool.class.getName()));
+
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
+    }
+}
diff --git a/metrics-jetty8/pom.xml b/metrics-jetty8/pom.xml
deleted file mode 100644
index 1efeee1..0000000
--- a/metrics-jetty8/pom.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<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>io.dropwizard.metrics</groupId>
-        <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
-    </parent>
-
-    <artifactId>metrics-jetty8</artifactId>
-    <name>Metrics Integration for Jetty 8</name>
-    <packaging>bundle</packaging>
-    <description>
-        A set of extensions for Jetty 8 which provide instrumentation of thread pools, connector
-        metrics, and application latency and utilization.
-    </description>
-
-    <dependencies>
-        <dependency>
-            <groupId>io.dropwizard.metrics</groupId>
-            <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.eclipse.jetty</groupId>
-            <artifactId>jetty-server</artifactId>
-            <version>${jetty8.version}</version>
-        </dependency>
-    </dependencies>
-</project>
diff --git a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedBlockingChannelConnector.java b/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedBlockingChannelConnector.java
deleted file mode 100644
index d554e16..0000000
--- a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedBlockingChannelConnector.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.codahale.metrics.jetty8;
-
-import com.codahale.metrics.*;
-import org.eclipse.jetty.io.Connection;
-import org.eclipse.jetty.server.nio.BlockingChannelConnector;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-public class InstrumentedBlockingChannelConnector extends BlockingChannelConnector {
-    private final Timer duration;
-    private final Meter accepts, connects, disconnects;
-    private final Counter connections;
-    private final Clock clock;
-
-    public InstrumentedBlockingChannelConnector(MetricRegistry registry,
-                                                int port,
-                                                Clock clock) {
-        super();
-        this.clock = clock;
-        setPort(port);
-        this.duration = registry.timer(name(BlockingChannelConnector.class,
-                                            Integer.toString(port),
-                                            "connection-duration"));
-        this.accepts = registry.meter(name(BlockingChannelConnector.class,
-                                           Integer.toString(port),
-                                           "accepts"));
-        this.connects = registry.meter(name(BlockingChannelConnector.class,
-                                            Integer.toString(port),
-                                            "connects"));
-        this.disconnects = registry.meter(name(BlockingChannelConnector.class,
-                                               Integer.toString(port),
-                                               "disconnects"));
-        this.connections = registry.counter(name(BlockingChannelConnector.class,
-                                                 Integer.toString(port),
-                                                 "active-connections"));
-    }
-
-    @Override
-    public void accept(int acceptorID) throws IOException, InterruptedException {
-        super.accept(acceptorID);
-        accepts.mark();
-    }
-
-    @Override
-    protected void connectionOpened(Connection connection) {
-        connections.inc();
-        super.connectionOpened(connection);
-        connects.mark();
-    }
-
-    @Override
-    protected void connectionClosed(Connection connection) {
-        super.connectionClosed(connection);
-        disconnects.mark();
-        final long duration = clock.getTime() - connection.getTimeStamp();
-        this.duration.update(duration, TimeUnit.MILLISECONDS);
-        connections.dec();
-    }
-}
diff --git a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedHandler.java b/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedHandler.java
deleted file mode 100644
index f2cc8a0..0000000
--- a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedHandler.java
+++ /dev/null
@@ -1,248 +0,0 @@
-package com.codahale.metrics.jetty8;
-
-import com.codahale.metrics.*;
-import org.eclipse.jetty.continuation.Continuation;
-import org.eclipse.jetty.continuation.ContinuationListener;
-import org.eclipse.jetty.server.AsyncContinuation;
-import org.eclipse.jetty.server.Handler;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricRegistry.name;
-import static org.eclipse.jetty.http.HttpMethods.*;
-
-/**
- * A Jetty {@link Handler} which records various metrics about an underlying {@link Handler}
- * instance.
- */
-public class InstrumentedHandler extends HandlerWrapper {
-    private static final String PATCH = "PATCH";
-
-    private final Timer dispatches;
-    private final Meter requests;
-    private final Meter resumes;
-    private final Meter suspends;
-    private final Meter expires;
-
-    private final Counter activeRequests;
-    private final Counter activeSuspendedRequests;
-    private final Counter activeDispatches;
-
-    private final Meter[] responses;
-
-    private final Timer getRequests, postRequests, headRequests,
-            putRequests, deleteRequests, optionsRequests, traceRequests,
-            connectRequests, patchRequests, otherRequests;
-
-    private final ContinuationListener listener;
-
-    /**
-     * Create a new instrumented handler using a given metrics registry. The name of the metric will
-     * be derived from the class of the Handler.
-     *
-     * @param registry   the registry for the metrics
-     * @param underlying the handler about which metrics will be collected
-     */
-    public InstrumentedHandler(MetricRegistry registry, Handler underlying) {
-        this(registry, underlying, name(underlying.getClass()));
-    }
-
-    /**
-     * Create a new instrumented handler using a given metrics registry and a custom prefix.
-     *
-     * @param registry   the registry for the metrics
-     * @param underlying the handler about which metrics will be collected
-     * @param prefix     the prefix to use for the metrics names
-     */
-    public InstrumentedHandler(MetricRegistry registry, Handler underlying, String prefix) {
-        super();
-        this.dispatches = registry.timer(name(prefix, "dispatches"));
-        this.requests = registry.meter(name(prefix, "requests"));
-        this.resumes = registry.meter(name(prefix, "resumes"));
-        this.suspends = registry.meter(name(prefix, "suspends"));
-        this.expires = registry.meter(name(prefix, "expires"));
-
-        this.activeRequests = registry.counter(name(prefix, "active-requests"));
-        this.activeSuspendedRequests = registry.counter(name(prefix,
-                                                             "active-suspended-requests"));
-        this.activeDispatches = registry.counter(name(prefix, "active-dispatches"));
-
-        this.responses = new Meter[]{
-                registry.meter(name(prefix, "1xx-responses")), // 1xx
-                registry.meter(name(prefix, "2xx-responses")), // 2xx
-                registry.meter(name(prefix, "3xx-responses")), // 3xx
-                registry.meter(name(prefix, "4xx-responses")), // 4xx
-                registry.meter(name(prefix, "5xx-responses"))  // 5xx
-        };
-
-        registry.register(name(prefix, "percent-4xx-1m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getOneMinuteRate(),
-                                requests.getOneMinuteRate());
-            }
-        });
-
-        registry.register(name(prefix, "percent-4xx-5m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getFiveMinuteRate(),
-                                requests.getFiveMinuteRate());
-            }
-        });
-
-        registry.register(name(prefix, "percent-4xx-15m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getFifteenMinuteRate(),
-                                requests.getFifteenMinuteRate());
-            }
-        });
-
-        registry.register(name(prefix, "percent-5xx-1m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getOneMinuteRate(),
-                                requests.getOneMinuteRate());
-            }
-        });
-
-        registry.register(name(prefix, "percent-5xx-5m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getFiveMinuteRate(),
-                                requests.getFiveMinuteRate());
-            }
-        });
-
-        registry.register(name(prefix, "percent-5xx-15m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getFifteenMinuteRate(),
-                                requests.getFifteenMinuteRate());
-            }
-        });
-
-        this.listener = new ContinuationListener() {
-            @Override
-            public void onComplete(Continuation continuation) {
-                final Request request = ((AsyncContinuation) continuation).getBaseRequest();
-                updateResponses(request);
-                if (!continuation.isResumed()) {
-                    activeSuspendedRequests.dec();
-                }
-                expires.mark();
-            }
-
-            @Override
-            public void onTimeout(Continuation continuation) {
-                final Request request = ((AsyncContinuation) continuation).getBaseRequest();
-                updateResponses(request);
-                if (!continuation.isResumed()) {
-                    activeSuspendedRequests.dec();
-                }
-            }
-        };
-
-        this.getRequests = registry.timer(name(prefix, "get-requests"));
-        this.postRequests = registry.timer(name(prefix, "post-requests"));
-        this.headRequests = registry.timer(name(prefix, "head-requests"));
-        this.putRequests = registry.timer(name(prefix, "put-requests"));
-        this.deleteRequests = registry.timer(name(prefix, "delete-requests"));
-        this.optionsRequests = registry.timer(name(prefix, "options-requests"));
-        this.traceRequests = registry.timer(name(prefix, "trace-requests"));
-        this.connectRequests = registry.timer(name(prefix, "connect-requests"));
-        this.patchRequests = registry.timer(name(prefix, "patch-requests"));
-        this.otherRequests = registry.timer(name(prefix, "other-requests"));
-
-        setHandler(underlying);
-    }
-
-    @Override
-    public void handle(String target, Request request,
-                       HttpServletRequest httpRequest, HttpServletResponse httpResponse)
-            throws IOException, ServletException {
-        activeDispatches.inc();
-
-        final AsyncContinuation continuation = request.getAsyncContinuation();
-
-        final long start;
-        final boolean isMilliseconds;
-
-        if (continuation.isInitial()) {
-            activeRequests.inc();
-            start = request.getTimeStamp();
-            isMilliseconds = true;
-        } else {
-            activeSuspendedRequests.dec();
-            if (continuation.isResumed()) {
-                resumes.mark();
-            }
-            isMilliseconds = false;
-            start = System.nanoTime();
-        }
-
-        try {
-            super.handle(target, request, httpRequest, httpResponse);
-        } finally {
-            if (isMilliseconds) {
-                final long duration = System.currentTimeMillis() - start;
-                dispatches.update(duration, TimeUnit.MILLISECONDS);
-                requestTimer(request.getMethod()).update(duration, TimeUnit.MILLISECONDS);
-            } else {
-                final long duration = System.nanoTime() - start;
-                dispatches.update(duration, TimeUnit.NANOSECONDS);
-                requestTimer(request.getMethod()).update(duration, TimeUnit.NANOSECONDS);
-            }
-
-            activeDispatches.dec();
-            if (continuation.isSuspended()) {
-                if (continuation.isInitial()) {
-                    continuation.addContinuationListener(listener);
-                }
-                suspends.mark();
-                activeSuspendedRequests.inc();
-            } else if (continuation.isInitial()) {
-                updateResponses(request);
-            }
-        }
-    }
-
-    private Timer requestTimer(String method) {
-        if (GET.equalsIgnoreCase(method)) {
-            return getRequests;
-        } else if (POST.equalsIgnoreCase(method)) {
-            return postRequests;
-        } else if (PUT.equalsIgnoreCase(method)) {
-            return putRequests;
-        } else if (HEAD.equalsIgnoreCase(method)) {
-            return headRequests;
-        } else if (DELETE.equalsIgnoreCase(method)) {
-            return deleteRequests;
-        } else if (OPTIONS.equalsIgnoreCase(method)) {
-            return optionsRequests;
-        } else if (TRACE.equalsIgnoreCase(method)) {
-            return traceRequests;
-        } else if (CONNECT.equalsIgnoreCase(method)) {
-            return connectRequests;
-        } else if (PATCH.equalsIgnoreCase(method)) {
-            return patchRequests;
-        }
-        return otherRequests;
-    }
-
-    private void updateResponses(Request request) {
-        final int response = request.getResponse().getStatus() / 100;
-        if (response >= 1 && response <= 5) {
-            responses[response - 1].mark();
-        }
-        activeRequests.dec();
-        requests.mark();
-    }
-}
diff --git a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedQueuedThreadPool.java b/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedQueuedThreadPool.java
deleted file mode 100644
index aef0786..0000000
--- a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedQueuedThreadPool.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.codahale.metrics.jetty8;
-
-import com.codahale.metrics.Gauge;
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.RatioGauge;
-import org.eclipse.jetty.util.thread.QueuedThreadPool;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-public class InstrumentedQueuedThreadPool extends QueuedThreadPool {
-    public InstrumentedQueuedThreadPool(MetricRegistry registry) {
-        super();
-        registry.register(name(QueuedThreadPool.class, "percent-idle"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(getIdleThreads(),
-                                getThreads());
-            }
-        });
-        registry.register(name(QueuedThreadPool.class, "active-threads"), new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return getThreads();
-            }
-        });
-        registry.register(name(QueuedThreadPool.class, "idle-threads"), new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return getIdleThreads();
-            }
-        });
-        registry.register(name(QueuedThreadPool.class, "jobs"), new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                // This assumes the QueuedThreadPool is using a BlockingArrayQueue or
-                // ArrayBlockingQueue for its queue, and is therefore a constant-time operation.
-                return getQueue() != null ? getQueue().size() : 0;
-            }
-        });
-        registry.register(name(QueuedThreadPool.class, "utilization-max"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(getThreads() - getIdleThreads(), getMaxThreads());
-            }
-        });
-    }
-}
diff --git a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSelectChannelConnector.java b/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSelectChannelConnector.java
deleted file mode 100644
index ab494be..0000000
--- a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSelectChannelConnector.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.codahale.metrics.jetty8;
-
-import com.codahale.metrics.*;
-import org.eclipse.jetty.io.Connection;
-import org.eclipse.jetty.server.nio.SelectChannelConnector;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-public class InstrumentedSelectChannelConnector extends SelectChannelConnector {
-    private final Timer duration;
-    private final Meter accepts, connects, disconnects;
-    private final Counter connections;
-    private final Clock clock;
-
-    public InstrumentedSelectChannelConnector(MetricRegistry registry,
-                                              int port,
-                                              Clock clock) {
-        super();
-        this.clock = clock;
-        setPort(port);
-
-        this.duration = registry.timer(name(SelectChannelConnector.class,
-                                            Integer.toString(port),
-                                            "connection-duration"));
-        this.accepts = registry.meter(name(SelectChannelConnector.class,
-                                           Integer.toString(port),
-                                           "accepts"));
-        this.connects = registry.meter(name(SelectChannelConnector.class,
-                                            Integer.toString(port),
-                                            "connects"));
-        this.disconnects = registry.meter(name(SelectChannelConnector.class,
-                                               Integer.toString(port),
-                                               "disconnects"));
-        this.connections = registry.counter(name(SelectChannelConnector.class,
-                                                 Integer.toString(port),
-                                                 "active-connections"));
-    }
-
-    @Override
-    public void accept(int acceptorID) throws IOException {
-        super.accept(acceptorID);
-        accepts.mark();
-    }
-
-    @Override
-    protected void connectionOpened(Connection connection) {
-        connections.inc();
-        super.connectionOpened(connection);
-        connects.mark();
-    }
-
-    @Override
-    protected void connectionClosed(Connection connection) {
-        super.connectionClosed(connection);
-        disconnects.mark();
-        final long duration = clock.getTime() - connection.getTimeStamp();
-        this.duration.update(duration, TimeUnit.MILLISECONDS);
-        connections.dec();
-    }
-}
diff --git a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSocketConnector.java b/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSocketConnector.java
deleted file mode 100644
index 233f049..0000000
--- a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSocketConnector.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.codahale.metrics.jetty8;
-
-import com.codahale.metrics.*;
-import org.eclipse.jetty.io.Connection;
-import org.eclipse.jetty.server.bio.SocketConnector;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-public class InstrumentedSocketConnector extends SocketConnector {
-    private final Timer duration;
-    private final Meter accepts, connects, disconnects;
-    private final Counter connections;
-    private final Clock clock;
-
-    public InstrumentedSocketConnector(MetricRegistry registry,
-                                       int port,
-                                       Clock clock) {
-        super();
-        this.clock = clock;
-        setPort(port);
-        this.duration = registry.timer(name(SocketConnector.class,
-                                            Integer.toString(port),
-                                            "connection-duration"));
-        this.accepts = registry.meter(name(SocketConnector.class,
-                                           Integer.toString(port),
-                                           "accepts"));
-        this.connects = registry.meter(name(SocketConnector.class,
-                                            Integer.toString(port),
-                                            "connects"));
-        this.disconnects = registry.meter(name(SocketConnector.class,
-                                               Integer.toString(port),
-                                               "disconnects"));
-        this.connections = registry.counter(name(SocketConnector.class,
-                                                 Integer.toString(port),
-                                                 "active-connections"));
-    }
-
-    @Override
-    public void accept(int acceptorID) throws IOException, InterruptedException {
-        super.accept(acceptorID);
-        accepts.mark();
-    }
-
-    @Override
-    protected void connectionOpened(Connection connection) {
-        connections.inc();
-        super.connectionOpened(connection);
-        connects.mark();
-    }
-
-    @Override
-    protected void connectionClosed(Connection connection) {
-        super.connectionClosed(connection);
-        disconnects.mark();
-        final long duration = clock.getTime() - connection.getTimeStamp();
-        this.duration.update(duration, TimeUnit.MILLISECONDS);
-        connections.dec();
-    }
-}
diff --git a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSslSelectChannelConnector.java b/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSslSelectChannelConnector.java
deleted file mode 100644
index 21c2f15..0000000
--- a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSslSelectChannelConnector.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.codahale.metrics.jetty8;
-
-import com.codahale.metrics.*;
-import org.eclipse.jetty.io.Connection;
-import org.eclipse.jetty.server.ssl.SslSelectChannelConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-public class InstrumentedSslSelectChannelConnector extends SslSelectChannelConnector {
-    private final Timer duration;
-    private final Meter accepts, connects, disconnects;
-    private final Counter connections;
-    private final Clock clock;
-
-    public InstrumentedSslSelectChannelConnector(MetricRegistry registry,
-                                                 int port,
-                                                 SslContextFactory factory,
-                                                 Clock clock) {
-        super(factory);
-        this.clock = clock;
-        setPort(port);
-        this.duration = registry.timer(name(SslSelectChannelConnector.class,
-                                            Integer.toString(port),
-                                            "connection-duration"));
-        this.accepts = registry.meter(name(SslSelectChannelConnector.class,
-                                           Integer.toString(port),
-                                           "accepts"));
-        this.connects = registry.meter(name(SslSelectChannelConnector.class,
-                                            Integer.toString(port),
-                                            "connects"));
-        this.disconnects = registry.meter(name(SslSelectChannelConnector.class,
-                                               Integer.toString(port),
-                                               "disconnects"));
-        this.connections = registry.counter(name(SslSelectChannelConnector.class,
-                                                 Integer.toString(port),
-                                                 "active-connections"));
-
-    }
-
-    @Override
-    public void accept(int acceptorID) throws IOException {
-        super.accept(acceptorID);
-        accepts.mark();
-    }
-
-    @Override
-    protected void connectionOpened(Connection connection) {
-        connections.inc();
-        super.connectionOpened(connection);
-        connects.mark();
-    }
-
-    @Override
-    protected void connectionClosed(Connection connection) {
-        super.connectionClosed(connection);
-        disconnects.mark();
-        final long duration = clock.getTime() - connection.getTimeStamp();
-        this.duration.update(duration, TimeUnit.MILLISECONDS);
-        connections.dec();
-    }
-}
diff --git a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSslSocketConnector.java b/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSslSocketConnector.java
deleted file mode 100644
index 5182121..0000000
--- a/metrics-jetty8/src/main/java/com/codahale/metrics/jetty8/InstrumentedSslSocketConnector.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.codahale.metrics.jetty8;
-
-import com.codahale.metrics.*;
-import org.eclipse.jetty.io.Connection;
-import org.eclipse.jetty.server.ssl.SslSocketConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-public class InstrumentedSslSocketConnector extends SslSocketConnector {
-    private final Timer duration;
-    private final Meter accepts, connects, disconnects;
-    private final Counter connections;
-    private final Clock clock;
-
-    public InstrumentedSslSocketConnector(MetricRegistry registry,
-                                          int port,
-                                          SslContextFactory factory,
-                                          Clock clock) {
-        super(factory);
-        this.clock = clock;
-        setPort(port);
-        this.duration = registry.timer(name(SslSocketConnector.class,
-                                            Integer.toString(port),
-                                            "connection-duration"));
-        this.accepts = registry.meter(name(SslSocketConnector.class,
-                                           Integer.toString(port),
-                                           "accepts"));
-        this.connects = registry.meter(name(SslSocketConnector.class,
-                                            Integer.toString(port),
-                                            "connects"));
-        this.disconnects = registry.meter(name(SslSocketConnector.class,
-                                               Integer.toString(port),
-                                               "disconnects"));
-        this.connections = registry.counter(name(SslSocketConnector.class,
-                                                 Integer.toString(port),
-                                                 "active-connections"));
-    }
-
-    @Override
-    public void accept(int acceptorID) throws IOException, InterruptedException {
-        super.accept(acceptorID);
-        accepts.mark();
-    }
-
-    @Override
-    protected void connectionOpened(Connection connection) {
-        connections.inc();
-        super.connectionOpened(connection);
-        connects.mark();
-    }
-
-    @Override
-    protected void connectionClosed(Connection connection) {
-        super.connectionClosed(connection);
-        disconnects.mark();
-        final long duration = clock.getTime() - connection.getTimeStamp();
-        this.duration.update(duration, TimeUnit.MILLISECONDS);
-        connections.dec();
-    }
-}
diff --git a/metrics-jetty9-legacy/pom.xml b/metrics-jetty9-legacy/pom.xml
deleted file mode 100644
index 6a4f88c..0000000
--- a/metrics-jetty9-legacy/pom.xml
+++ /dev/null
@@ -1,51 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<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>io.dropwizard.metrics</groupId>
-        <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
-    </parent>
-
-    <artifactId>metrics-jetty9-legacy</artifactId>
-    <name>Metrics Integration for Jetty 9.0</name>
-    <packaging>bundle</packaging>
-    <description>
-        A set of extensions for Jetty 9.0 which provide instrumentation of thread pools, connector
-        metrics, and application latency and utilization.
-    </description>
-
-    <dependencies>
-        <dependency>
-            <groupId>io.dropwizard.metrics</groupId>
-            <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.eclipse.jetty</groupId>
-            <artifactId>jetty-server</artifactId>
-            <version>${jetty9.legacy.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.eclipse.jetty</groupId>
-            <artifactId>jetty-client</artifactId>
-            <version>${jetty9.legacy.version}</version>
-            <scope>test</scope>
-        </dependency>
-    </dependencies>
-    
-    <build>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-compiler-plugin</artifactId>
-                <version>3.1</version>
-                <configuration>
-                    <source>1.7</source>
-                    <target>1.7</target>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
-</project>
diff --git a/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedHandler.java b/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedHandler.java
deleted file mode 100644
index 80082c9..0000000
--- a/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedHandler.java
+++ /dev/null
@@ -1,300 +0,0 @@
-package com.codahale.metrics.jetty9;
-
-import com.codahale.metrics.Counter;
-import com.codahale.metrics.Meter;
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.RatioGauge;
-import com.codahale.metrics.Timer;
-import org.eclipse.jetty.http.HttpMethod;
-import org.eclipse.jetty.server.AsyncContextState;
-import org.eclipse.jetty.server.Handler;
-import org.eclipse.jetty.server.HttpChannelState;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.server.handler.HandlerWrapper;
-
-import javax.servlet.AsyncEvent;
-import javax.servlet.AsyncListener;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-/**
- * A Jetty {@link Handler} which records various metrics about an underlying {@link Handler}
- * instance.
- */
-public class InstrumentedHandler extends HandlerWrapper {
-    private final MetricRegistry metricRegistry;
-
-    private String name;
-    private final String prefix;
-
-    // the requests handled by this handler, excluding active
-    private Timer requests;
-
-    // the number of dispatches seen by this handler, excluding active
-    private Timer dispatches;
-
-    // the number of active requests
-    private Counter activeRequests;
-
-    // the number of active dispatches
-    private Counter activeDispatches;
-
-    // the number of requests currently suspended.
-    private Counter activeSuspended;
-
-    // the number of requests that have been asynchronously dispatched
-    private Meter asyncDispatches;
-
-    // the number of requests that expired while suspended
-    private Meter asyncTimeouts;
-
-    private Meter[] responses;
-
-    private Timer getRequests;
-    private Timer postRequests;
-    private Timer headRequests;
-    private Timer putRequests;
-    private Timer deleteRequests;
-    private Timer optionsRequests;
-    private Timer traceRequests;
-    private Timer connectRequests;
-    private Timer moveRequests;
-    private Timer otherRequests;
-
-    private AsyncListener listener;
-
-    /**
-     * Create a new instrumented handler using a given metrics registry.
-     *
-     * @param registry   the registry for the metrics
-     *
-     */
-    public InstrumentedHandler(MetricRegistry registry) {
-        this(registry, null);
-    }
-
-	/**
-	 * Create a new instrumented handler using a given metrics registry.
-	 *
-	 * @param registry   the registry for the metrics
-	 * @param prefix     the prefix to use for the metrics names
-	 *
-	 */
-	public InstrumentedHandler(MetricRegistry registry, String prefix) {
-		this.metricRegistry = registry;
-		this.prefix = prefix;
-	}
-
-    public String getName() {
-        return name;
-    }
-
-    public void setName(String name) {
-        this.name = name;
-    }
-
-    @Override
-    protected void doStart() throws Exception {
-        super.doStart();
-
-        final String prefix = this.prefix == null ? name(getHandler().getClass(), name) : name(this.prefix, name);
-
-        this.requests = metricRegistry.timer(name(prefix, "requests"));
-        this.dispatches = metricRegistry.timer(name(prefix, "dispatches"));
-
-        this.activeRequests = metricRegistry.counter(name(prefix, "active-requests"));
-        this.activeDispatches = metricRegistry.counter(name(prefix, "active-dispatches"));
-        this.activeSuspended = metricRegistry.counter(name(prefix, "active-suspended"));
-
-        this.asyncDispatches = metricRegistry.meter(name(prefix, "async-dispatches"));
-        this.asyncTimeouts = metricRegistry.meter(name(prefix, "async-timeouts"));
-
-        this.responses = new Meter[]{
-                metricRegistry.meter(name(prefix, "1xx-responses")), // 1xx
-                metricRegistry.meter(name(prefix, "2xx-responses")), // 2xx
-                metricRegistry.meter(name(prefix, "3xx-responses")), // 3xx
-                metricRegistry.meter(name(prefix, "4xx-responses")), // 4xx
-                metricRegistry.meter(name(prefix, "5xx-responses"))  // 5xx
-        };
-
-        this.getRequests = metricRegistry.timer(name(prefix, "get-requests"));
-        this.postRequests = metricRegistry.timer(name(prefix, "post-requests"));
-        this.headRequests = metricRegistry.timer(name(prefix, "head-requests"));
-        this.putRequests = metricRegistry.timer(name(prefix, "put-requests"));
-        this.deleteRequests = metricRegistry.timer(name(prefix, "delete-requests"));
-        this.optionsRequests = metricRegistry.timer(name(prefix, "options-requests"));
-        this.traceRequests = metricRegistry.timer(name(prefix, "trace-requests"));
-        this.connectRequests = metricRegistry.timer(name(prefix, "connect-requests"));
-        this.moveRequests = metricRegistry.timer(name(prefix, "move-requests"));
-        this.otherRequests = metricRegistry.timer(name(prefix, "other-requests"));
-
-        metricRegistry.register(name(prefix, "percent-4xx-1m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getOneMinuteRate(),
-                        requests.getOneMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-4xx-5m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getFiveMinuteRate(),
-                        requests.getFiveMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-4xx-15m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getFifteenMinuteRate(),
-                        requests.getFifteenMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-5xx-1m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getOneMinuteRate(),
-                        requests.getOneMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-5xx-5m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getFiveMinuteRate(),
-                        requests.getFiveMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-5xx-15m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getFifteenMinuteRate(),
-                        requests.getFifteenMinuteRate());
-            }
-        });
-
-
-        this.listener = new AsyncListener() {
-            private long startTime;
-
-            @Override
-            public void onTimeout(AsyncEvent event) throws IOException {
-                asyncTimeouts.mark();
-            }
-
-            @Override
-            public void onStartAsync(AsyncEvent event) throws IOException {
-                startTime = System.currentTimeMillis();
-                event.getAsyncContext().addListener(this);
-            }
-
-            @Override
-            public void onError(AsyncEvent event) throws IOException {
-            }
-
-            @Override
-            public void onComplete(AsyncEvent event) throws IOException {
-                final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
-                final HttpServletRequest request = (HttpServletRequest) state.getRequest();
-                final HttpServletResponse response = (HttpServletResponse) state.getResponse();
-                updateResponses(request, response, startTime);
-                if (!state.getHttpChannelState().isDispatched()) {
-                    activeSuspended.dec();
-                }
-            }
-        };
-    }
-
-    @Override
-    public void handle(String path,
-                       Request request,
-                       HttpServletRequest httpRequest,
-                       HttpServletResponse httpResponse) throws IOException, ServletException {
-
-        activeDispatches.inc();
-
-        final long start;
-        final HttpChannelState state = request.getHttpChannelState();
-        if (state.isInitial()) {
-            // new request
-            activeRequests.inc();
-            start = request.getTimeStamp();
-        } else {
-            // resumed request
-            start = System.currentTimeMillis();
-            activeSuspended.dec();
-            if (state.isDispatched()) {
-                asyncDispatches.mark();
-            }
-        }
-
-        try {
-            super.handle(path, request, httpRequest, httpResponse);
-        } finally {
-            final long now = System.currentTimeMillis();
-            final long dispatched = now - start;
-
-            activeDispatches.dec();
-            dispatches.update(dispatched, TimeUnit.MILLISECONDS);
-
-            if (state.isSuspended()) {
-                if (state.isInitial()) {
-                    state.addListener(listener);
-                }
-                activeSuspended.inc();
-            } else if (state.isInitial()) {
-                updateResponses(httpRequest, httpResponse, start);
-            }
-            // else onCompletion will handle it.
-        }
-    }
-
-    private Timer requestTimer(String method) {
-        final HttpMethod m = HttpMethod.fromString(method);
-        if (m == null) {
-            return otherRequests;
-        } else {
-            switch (m) {
-                case GET:
-                    return getRequests;
-                case POST:
-                    return postRequests;
-                case PUT:
-                    return putRequests;
-                case HEAD:
-                    return headRequests;
-                case DELETE:
-                    return deleteRequests;
-                case OPTIONS:
-                    return optionsRequests;
-                case TRACE:
-                    return traceRequests;
-                case CONNECT:
-                    return connectRequests;
-                case MOVE:
-                    return moveRequests;
-                default:
-                    return otherRequests;
-            }
-        }
-    }
-
-    private void updateResponses(HttpServletRequest request, HttpServletResponse response, long start) {
-        final int responseStatus = response.getStatus() / 100;
-        if (responseStatus >= 1 && responseStatus <= 5) {
-            responses[responseStatus - 1].mark();
-        }
-        activeRequests.dec();
-        final long elapsedTime = System.currentTimeMillis() - start;
-        requests.update(elapsedTime, TimeUnit.MILLISECONDS);
-        requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
-    }
-}
diff --git a/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPool.java b/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPool.java
deleted file mode 100644
index ac4114d..0000000
--- a/metrics-jetty9-legacy/src/main/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPool.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.codahale.metrics.jetty9;
-
-import com.codahale.metrics.Gauge;
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.RatioGauge;
-import org.eclipse.jetty.util.annotation.Name;
-import org.eclipse.jetty.util.thread.QueuedThreadPool;
-
-import java.util.concurrent.BlockingQueue;
-
-import static com.codahale.metrics.MetricRegistry.name;
-
-public class InstrumentedQueuedThreadPool extends QueuedThreadPool {
-    private final MetricRegistry metricRegistry;
-
-    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry) {
-        this(registry, 200);
-    }
-
-    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
-                                        @Name("maxThreads") int maxThreads) {
-        this(registry, maxThreads, 8);
-    }
-
-    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
-                                        @Name("maxThreads") int maxThreads,
-                                        @Name("minThreads") int minThreads) {
-        this(registry, maxThreads, minThreads, 60000);
-    }
-
-    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
-                                        @Name("maxThreads") int maxThreads,
-                                        @Name("minThreads") int minThreads,
-                                        @Name("idleTimeout") int idleTimeout) {
-        this(registry, maxThreads, minThreads, idleTimeout, null);
-    }
-
-    public InstrumentedQueuedThreadPool(@Name("registry") MetricRegistry registry,
-                                        @Name("maxThreads") int maxThreads,
-                                        @Name("minThreads") int minThreads,
-                                        @Name("idleTimeout") int idleTimeout,
-                                        @Name("queue") BlockingQueue<Runnable> queue) {
-        super(maxThreads, minThreads, idleTimeout, queue);
-        this.metricRegistry = registry;
-    }
-
-    @Override
-    protected void doStart() throws Exception {
-        super.doStart();
-        metricRegistry.register(name(QueuedThreadPool.class, getName(), "utilization"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(getThreads() - getIdleThreads(), getThreads());
-            }
-        });
-        metricRegistry.register(name(QueuedThreadPool.class, getName(), "utilization-max"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(getThreads() - getIdleThreads(), getMaxThreads());
-            }
-        });
-        metricRegistry.register(name(QueuedThreadPool.class, getName(), "size"), new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return getThreads();
-            }
-        });
-        metricRegistry.register(name(QueuedThreadPool.class, getName(), "jobs"), new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                // This assumes the QueuedThreadPool is using a BlockingArrayQueue or
-                // ArrayBlockingQueue for its queue, and is therefore a constant-time operation.
-                return getQueue().size();
-            }
-        });
-    }
-}
diff --git a/metrics-jetty9-legacy/src/test/java/com/codahale/metrics/jetty9/InstrumentedHandlerTest.java b/metrics-jetty9-legacy/src/test/java/com/codahale/metrics/jetty9/InstrumentedHandlerTest.java
deleted file mode 100644
index 9b7c86b..0000000
--- a/metrics-jetty9-legacy/src/test/java/com/codahale/metrics/jetty9/InstrumentedHandlerTest.java
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.codahale.metrics.jetty9;
-
-import com.codahale.metrics.MetricRegistry;
-import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.client.api.ContentResponse;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.server.handler.DefaultHandler;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-public class InstrumentedHandlerTest {
-    private final HttpClient client = new HttpClient();
-    private final MetricRegistry registry = new MetricRegistry();
-    private final Server server = new Server();
-    private final ServerConnector connector = new ServerConnector(server);
-    private final InstrumentedHandler handler = new InstrumentedHandler(registry);
-
-    @Before
-    public void setUp() throws Exception {
-        handler.setName("handler");
-        handler.setHandler(new DefaultHandler());
-        server.addConnector(connector);
-        server.setHandler(handler);
-        server.start();
-        client.start();
-    }
-
-    @After
-    public void tearDown() throws Exception {
-        server.stop();
-        client.stop();
-    }
-
-    @Test
-    public void hasAName() throws Exception {
-        assertThat(handler.getName())
-                .isEqualTo("handler");
-    }
-
-    @Test
-    public void createsMetricsForTheHandler() throws Exception {
-        final ContentResponse response = client.GET("http://localhost:" + connector.getLocalPort() + "/hello");
-
-        assertThat(response.getStatus())
-                .isEqualTo(404);
-
-        assertThat(registry.getNames())
-                .containsOnly(
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.1xx-responses",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.2xx-responses",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.3xx-responses",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.4xx-responses",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.5xx-responses",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.percent-4xx-1m",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.percent-4xx-5m",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.percent-4xx-15m",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.percent-5xx-1m",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.percent-5xx-5m",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.percent-5xx-15m",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.active-suspended",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.async-dispatches",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.async-timeouts",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.get-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.put-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.active-dispatches",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.trace-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.other-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.connect-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.dispatches",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.head-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.post-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.options-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.active-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.delete-requests",
-                        "org.eclipse.jetty.server.handler.DefaultHandler.handler.move-requests"
-                );
-    }
-}
diff --git a/metrics-jetty9/pom.xml b/metrics-jetty9/pom.xml
index 66cd76e..00fae98 100644
--- a/metrics-jetty9/pom.xml
+++ b/metrics-jetty9/pom.xml
@@ -5,47 +5,92 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-jetty9</artifactId>
-    <name>Metrics Integration for Jetty 9.1 and higher</name>
+    <name>Metrics Integration for Jetty 9.3 and higher</name>
     <packaging>bundle</packaging>
     <description>
-        A set of extensions for Jetty 9.1 and higher which provide instrumentation of thread pools, connector
+        A set of extensions for Jetty 9.3 and higher which provide instrumentation of thread pools, connector
         metrics, and application latency and utilization.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.jetty9</javaModuleName>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-bom</artifactId>
+                <version>${jetty9.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-annotation</artifactId>
         </dependency>
         <dependency>
             <groupId>org.eclipse.jetty</groupId>
             <artifactId>jetty-server</artifactId>
-            <version>${jetty9.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-io</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-util</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>javax.servlet-api</artifactId>
+            <version>3.1.0</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>org.eclipse.jetty</groupId>
             <artifactId>jetty-client</artifactId>
-            <version>${jetty9.version}</version>
             <scope>test</scope>
         </dependency>
     </dependencies>
-    
-    <build>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-compiler-plugin</artifactId>
-                <version>3.1</version>
-                <configuration>
-                    <source>1.7</source>
-                    <target>1.7</target>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
 </project>
diff --git a/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactory.java b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactory.java
index ce218e7..1d64a3e 100644
--- a/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactory.java
+++ b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactory.java
@@ -1,5 +1,6 @@
 package com.codahale.metrics.jetty9;
 
+import com.codahale.metrics.Counter;
 import com.codahale.metrics.Timer;
 import org.eclipse.jetty.io.Connection;
 import org.eclipse.jetty.io.EndPoint;
@@ -7,25 +8,22 @@ import org.eclipse.jetty.server.ConnectionFactory;
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.util.component.ContainerLifeCycle;
 
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.util.Collections;
 import java.util.List;
 
 public class InstrumentedConnectionFactory extends ContainerLifeCycle implements ConnectionFactory {
     private final ConnectionFactory connectionFactory;
     private final Timer timer;
-    private Method getProtocols;
+    private final Counter counter;
 
     public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer) {
+        this(connectionFactory, timer, null);
+    }
+
+    public InstrumentedConnectionFactory(ConnectionFactory connectionFactory, Timer timer, Counter counter) {
         this.connectionFactory = connectionFactory;
         this.timer = timer;
+        this.counter = counter;
         addBean(connectionFactory);
-        try {
-            getProtocols = connectionFactory.getClass().getMethod("getProtocols");
-        } catch (NoSuchMethodException ignore) {
-            getProtocols = null;
-        }
     }
 
     @Override
@@ -33,15 +31,9 @@ public class InstrumentedConnectionFactory extends ContainerLifeCycle implements
         return connectionFactory.getProtocol();
     }
 
-    @SuppressWarnings("unchecked")
+    @Override
     public List<String> getProtocols() {
-        try {
-            return getProtocols != null ?
-                    (List<String>) getProtocols.invoke(connectionFactory) :
-                    Collections.<String>emptyList();
-        } catch (IllegalAccessException | InvocationTargetException e) {
-            throw new IllegalStateException("Unable to invoke `connectionFactory#getProtocols`", e);
-        }
+        return connectionFactory.getProtocols();
     }
 
     @Override
@@ -53,11 +45,17 @@ public class InstrumentedConnectionFactory extends ContainerLifeCycle implements
             @Override
             public void onOpened(Connection connection) {
                 this.context = timer.time();
+                if (counter != null) {
+                    counter.inc();
+                }
             }
 
             @Override
             public void onClosed(Connection connection) {
                 context.stop();
+                if (counter != null) {
+                    counter.dec();
+                }
             }
         });
         return connection;
diff --git a/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedHandler.java b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedHandler.java
index 0543fe9..2447161 100644
--- a/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedHandler.java
+++ b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedHandler.java
@@ -5,6 +5,7 @@ import com.codahale.metrics.Meter;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.RatioGauge;
 import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
 import org.eclipse.jetty.http.HttpMethod;
 import org.eclipse.jetty.server.AsyncContextState;
 import org.eclipse.jetty.server.Handler;
@@ -18,15 +19,56 @@ import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 
 import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
 
 /**
  * A Jetty {@link Handler} which records various metrics about an underlying {@link Handler}
  * instance.
  */
 public class InstrumentedHandler extends HandlerWrapper {
+    private static final String NAME_REQUESTS = "requests";
+    private static final String NAME_DISPATCHES = "dispatches";
+    private static final String NAME_ACTIVE_REQUESTS = "active-requests";
+    private static final String NAME_ACTIVE_DISPATCHES = "active-dispatches";
+    private static final String NAME_ACTIVE_SUSPENDED = "active-suspended";
+    private static final String NAME_ASYNC_DISPATCHES = "async-dispatches";
+    private static final String NAME_ASYNC_TIMEOUTS = "async-timeouts";
+    private static final String NAME_1XX_RESPONSES = "1xx-responses";
+    private static final String NAME_2XX_RESPONSES = "2xx-responses";
+    private static final String NAME_3XX_RESPONSES = "3xx-responses";
+    private static final String NAME_4XX_RESPONSES = "4xx-responses";
+    private static final String NAME_5XX_RESPONSES = "5xx-responses";
+    private static final String NAME_GET_REQUESTS = "get-requests";
+    private static final String NAME_POST_REQUESTS = "post-requests";
+    private static final String NAME_HEAD_REQUESTS = "head-requests";
+    private static final String NAME_PUT_REQUESTS = "put-requests";
+    private static final String NAME_DELETE_REQUESTS = "delete-requests";
+    private static final String NAME_OPTIONS_REQUESTS = "options-requests";
+    private static final String NAME_TRACE_REQUESTS = "trace-requests";
+    private static final String NAME_CONNECT_REQUESTS = "connect-requests";
+    private static final String NAME_MOVE_REQUESTS = "move-requests";
+    private static final String NAME_OTHER_REQUESTS = "other-requests";
+    private static final String NAME_PERCENT_4XX_1M = "percent-4xx-1m";
+    private static final String NAME_PERCENT_4XX_5M = "percent-4xx-5m";
+    private static final String NAME_PERCENT_4XX_15M = "percent-4xx-15m";
+    private static final String NAME_PERCENT_5XX_1M = "percent-5xx-1m";
+    private static final String NAME_PERCENT_5XX_5M = "percent-5xx-5m";
+    private static final String NAME_PERCENT_5XX_15M = "percent-5xx-15m";
+    private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+    private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+
     private final MetricRegistry metricRegistry;
 
     private String name;
@@ -53,7 +95,9 @@ public class InstrumentedHandler extends HandlerWrapper {
     // the number of requests that expired while suspended
     private Meter asyncTimeouts;
 
-    private Meter[] responses;
+    private final ResponseMeteredLevel responseMeteredLevel;
+    private List<Meter> responses;
+    private Map<Integer, Meter> responseCodeMeters;
 
     private Timer getRequests;
     private Timer postRequests;
@@ -68,27 +112,45 @@ public class InstrumentedHandler extends HandlerWrapper {
 
     private AsyncListener listener;
 
+    private HttpChannelState.State DISPATCHED_HACK;
+
     /**
      * Create a new instrumented handler using a given metrics registry.
      *
-     * @param registry   the registry for the metrics
-     *
+     * @param registry the registry for the metrics
      */
     public InstrumentedHandler(MetricRegistry registry) {
         this(registry, null);
     }
 
-	/**
-	 * Create a new instrumented handler using a given metrics registry.
-	 *
-	 * @param registry   the registry for the metrics
-	 * @param prefix     the prefix to use for the metrics names
-	 *
-	 */
-	public InstrumentedHandler(MetricRegistry registry, String prefix) {
-		this.metricRegistry = registry;
-		this.prefix = prefix;
-	}
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     */
+    public InstrumentedHandler(MetricRegistry registry, String prefix) {
+        this(registry, prefix, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param prefix   the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    public InstrumentedHandler(MetricRegistry registry, String prefix, ResponseMeteredLevel responseMeteredLevel) {
+        this.metricRegistry = registry;
+        this.prefix = prefix;
+        this.responseMeteredLevel = responseMeteredLevel;
+
+        try {
+            DISPATCHED_HACK = HttpChannelState.State.valueOf("HANDLING");
+        } catch (IllegalArgumentException e) {
+            DISPATCHED_HACK = HttpChannelState.State.valueOf("DISPATCHED");
+        }
+    }
 
     public String getName() {
         return name;
@@ -102,115 +164,134 @@ public class InstrumentedHandler extends HandlerWrapper {
     protected void doStart() throws Exception {
         super.doStart();
 
-        final String prefix = this.prefix == null ? name(getHandler().getClass(), name) : name(this.prefix, name);
-
-        this.requests = metricRegistry.timer(name(prefix, "requests"));
-        this.dispatches = metricRegistry.timer(name(prefix, "dispatches"));
-
-        this.activeRequests = metricRegistry.counter(name(prefix, "active-requests"));
-        this.activeDispatches = metricRegistry.counter(name(prefix, "active-dispatches"));
-        this.activeSuspended = metricRegistry.counter(name(prefix, "active-suspended"));
-
-        this.asyncDispatches = metricRegistry.meter(name(prefix, "async-dispatches"));
-        this.asyncTimeouts = metricRegistry.meter(name(prefix, "async-timeouts"));
-
-        this.responses = new Meter[]{
-                metricRegistry.meter(name(prefix, "1xx-responses")), // 1xx
-                metricRegistry.meter(name(prefix, "2xx-responses")), // 2xx
-                metricRegistry.meter(name(prefix, "3xx-responses")), // 3xx
-                metricRegistry.meter(name(prefix, "4xx-responses")), // 4xx
-                metricRegistry.meter(name(prefix, "5xx-responses"))  // 5xx
-        };
-
-        this.getRequests = metricRegistry.timer(name(prefix, "get-requests"));
-        this.postRequests = metricRegistry.timer(name(prefix, "post-requests"));
-        this.headRequests = metricRegistry.timer(name(prefix, "head-requests"));
-        this.putRequests = metricRegistry.timer(name(prefix, "put-requests"));
-        this.deleteRequests = metricRegistry.timer(name(prefix, "delete-requests"));
-        this.optionsRequests = metricRegistry.timer(name(prefix, "options-requests"));
-        this.traceRequests = metricRegistry.timer(name(prefix, "trace-requests"));
-        this.connectRequests = metricRegistry.timer(name(prefix, "connect-requests"));
-        this.moveRequests = metricRegistry.timer(name(prefix, "move-requests"));
-        this.otherRequests = metricRegistry.timer(name(prefix, "other-requests"));
-
-        metricRegistry.register(name(prefix, "percent-4xx-1m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getOneMinuteRate(),
-                        requests.getOneMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-4xx-5m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getFiveMinuteRate(),
-                        requests.getFiveMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-4xx-15m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[3].getFifteenMinuteRate(),
-                        requests.getFifteenMinuteRate());
-            }
-        });
-
-        metricRegistry.register(name(prefix, "percent-5xx-1m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getOneMinuteRate(),
-                        requests.getOneMinuteRate());
-            }
-        });
+        final String prefix = getMetricPrefix();
+
+        this.requests = metricRegistry.timer(name(prefix, NAME_REQUESTS));
+        this.dispatches = metricRegistry.timer(name(prefix, NAME_DISPATCHES));
+
+        this.activeRequests = metricRegistry.counter(name(prefix, NAME_ACTIVE_REQUESTS));
+        this.activeDispatches = metricRegistry.counter(name(prefix, NAME_ACTIVE_DISPATCHES));
+        this.activeSuspended = metricRegistry.counter(name(prefix, NAME_ACTIVE_SUSPENDED));
+
+        this.asyncDispatches = metricRegistry.meter(name(prefix, NAME_ASYNC_DISPATCHES));
+        this.asyncTimeouts = metricRegistry.meter(name(prefix, NAME_ASYNC_TIMEOUTS));
+
+        this.responseCodeMeters = DETAILED_METER_LEVELS.contains(responseMeteredLevel) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+
+        this.getRequests = metricRegistry.timer(name(prefix, NAME_GET_REQUESTS));
+        this.postRequests = metricRegistry.timer(name(prefix, NAME_POST_REQUESTS));
+        this.headRequests = metricRegistry.timer(name(prefix, NAME_HEAD_REQUESTS));
+        this.putRequests = metricRegistry.timer(name(prefix, NAME_PUT_REQUESTS));
+        this.deleteRequests = metricRegistry.timer(name(prefix, NAME_DELETE_REQUESTS));
+        this.optionsRequests = metricRegistry.timer(name(prefix, NAME_OPTIONS_REQUESTS));
+        this.traceRequests = metricRegistry.timer(name(prefix, NAME_TRACE_REQUESTS));
+        this.connectRequests = metricRegistry.timer(name(prefix, NAME_CONNECT_REQUESTS));
+        this.moveRequests = metricRegistry.timer(name(prefix, NAME_MOVE_REQUESTS));
+        this.otherRequests = metricRegistry.timer(name(prefix, NAME_OTHER_REQUESTS));
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            this.responses = Collections.unmodifiableList(Arrays.asList(
+                    metricRegistry.meter(name(prefix, NAME_1XX_RESPONSES)), // 1xx
+                    metricRegistry.meter(name(prefix, NAME_2XX_RESPONSES)), // 2xx
+                    metricRegistry.meter(name(prefix, NAME_3XX_RESPONSES)), // 3xx
+                    metricRegistry.meter(name(prefix, NAME_4XX_RESPONSES)), // 4xx
+                    metricRegistry.meter(name(prefix, NAME_5XX_RESPONSES))  // 5xx
+            ));
+
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
 
-        metricRegistry.register(name(prefix, "percent-5xx-5m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getFiveMinuteRate(),
-                        requests.getFiveMinuteRate());
-            }
-        });
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
 
-        metricRegistry.register(name(prefix, "percent-5xx-15m"), new RatioGauge() {
-            @Override
-            protected Ratio getRatio() {
-                return Ratio.of(responses[4].getFifteenMinuteRate(),
-                        requests.getFifteenMinuteRate());
-            }
-        });
+            metricRegistry.register(name(prefix, NAME_PERCENT_4XX_15M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(3).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
 
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_1M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getOneMinuteRate(),
+                            requests.getOneMinuteRate());
+                }
+            });
 
-        this.listener = new AsyncListener() {
-            private long startTime;
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_5M), new RatioGauge() {
+                @Override
+                protected Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFiveMinuteRate(),
+                            requests.getFiveMinuteRate());
+                }
+            });
 
-            @Override
-            public void onTimeout(AsyncEvent event) throws IOException {
-                asyncTimeouts.mark();
-            }
+            metricRegistry.register(name(prefix, NAME_PERCENT_5XX_15M), new RatioGauge() {
+                @Override
+                public Ratio getRatio() {
+                    return Ratio.of(responses.get(4).getFifteenMinuteRate(),
+                            requests.getFifteenMinuteRate());
+                }
+            });
+        } else {
+            this.responses = Collections.emptyList();
+        }
 
-            @Override
-            public void onStartAsync(AsyncEvent event) throws IOException {
-                startTime = System.currentTimeMillis();
-                event.getAsyncContext().addListener(this);
-            }
 
-            @Override
-            public void onError(AsyncEvent event) throws IOException {
-            }
+        this.listener = new AsyncAttachingListener();
+    }
 
-            @Override
-            public void onComplete(AsyncEvent event) throws IOException {
-                final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
-                final HttpServletRequest request = (HttpServletRequest) state.getRequest();
-                final HttpServletResponse response = (HttpServletResponse) state.getResponse();
-                updateResponses(request, response, startTime, true);
-                if (state.getHttpChannelState().getState() != HttpChannelState.State.DISPATCHED) {
-                    activeSuspended.dec();
-                }
-            }
-        };
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ACTIVE_SUSPENDED));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_DISPATCHES));
+        metricRegistry.remove(name(prefix, NAME_ASYNC_TIMEOUTS));
+        metricRegistry.remove(name(prefix, NAME_1XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_2XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_3XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_4XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_5XX_RESPONSES));
+        metricRegistry.remove(name(prefix, NAME_GET_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_POST_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_HEAD_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PUT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_DELETE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OPTIONS_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_TRACE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_CONNECT_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_MOVE_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_OTHER_REQUESTS));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_4XX_15M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_1M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_5M));
+        metricRegistry.remove(name(prefix, NAME_PERCENT_5XX_15M));
+
+        if (responseCodeMeters != null) {
+            responseCodeMeters.keySet().stream()
+                    .map(sc -> name(getMetricPrefix(), String.format("%d-responses", sc)))
+                    .forEach(metricRegistry::remove);
+        }
+        super.doStop();
     }
 
     @Override
@@ -232,7 +313,7 @@ public class InstrumentedHandler extends HandlerWrapper {
             // resumed request
             start = System.currentTimeMillis();
             activeSuspended.dec();
-            if (state.getState() == HttpChannelState.State.DISPATCHED) {
+            if (state.getState() == DISPATCHED_HACK) {
                 asyncDispatches.mark();
             }
         }
@@ -286,18 +367,86 @@ public class InstrumentedHandler extends HandlerWrapper {
     }
 
     private void updateResponses(HttpServletRequest request, HttpServletResponse response, long start, boolean isHandled) {
-        final int responseStatus;
         if (isHandled) {
-            responseStatus = response.getStatus() / 100;
+            mark(response.getStatus());
         } else {
-            responseStatus = 4; // will end up with a 404 response sent by HttpChannel.handle
-        }
-        if (responseStatus >= 1 && responseStatus <= 5) {
-            responses[responseStatus - 1].mark();
+            mark(404);; // will end up with a 404 response sent by HttpChannel.handle
         }
         activeRequests.dec();
         final long elapsedTime = System.currentTimeMillis() - start;
         requests.update(elapsedTime, TimeUnit.MILLISECONDS);
         requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
     }
+
+    private void mark(int statusCode) {
+        if (DETAILED_METER_LEVELS.contains(responseMeteredLevel)) {
+            getResponseCodeMeter(statusCode).mark();
+        }
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            final int responseStatus = statusCode / 100;
+            if (responseStatus >= 1 && responseStatus <= 5) {
+                responses.get(responseStatus - 1).mark();
+            }
+        }
+    }
+
+    private Meter getResponseCodeMeter(int statusCode) {
+        return responseCodeMeters
+                .computeIfAbsent(statusCode, sc -> metricRegistry
+                        .meter(name(getMetricPrefix(), String.format("%d-responses", sc))));
+    }
+
+    private String getMetricPrefix() {
+        return this.prefix == null ? name(getHandler().getClass(), name) : name(this.prefix, name);
+    }
+
+    private class AsyncAttachingListener implements AsyncListener {
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+            event.getAsyncContext().addListener(new InstrumentedAsyncListener());
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {}
+    };
+
+    private class InstrumentedAsyncListener implements AsyncListener {
+        private final long startTime;
+
+        InstrumentedAsyncListener() {
+            this.startTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            asyncTimeouts.mark();
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
+            final HttpServletRequest request = (HttpServletRequest) state.getRequest();
+            final HttpServletResponse response = (HttpServletResponse) state.getResponse();
+            updateResponses(request, response, startTime, true);
+            if (!state.getHttpChannelState().isSuspended()) {
+                activeSuspended.dec();
+            }
+        }
+    }
 }
diff --git a/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedHttpChannelListener.java b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedHttpChannelListener.java
new file mode 100644
index 0000000..8b41f3e
--- /dev/null
+++ b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedHttpChannelListener.java
@@ -0,0 +1,426 @@
+package com.codahale.metrics.jetty9;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.RatioGauge;
+import com.codahale.metrics.Timer;
+import com.codahale.metrics.annotation.ResponseMeteredLevel;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.server.AsyncContextState;
+import org.eclipse.jetty.server.HttpChannel.Listener;
+import org.eclipse.jetty.server.HttpChannelState;
+import org.eclipse.jetty.server.Request;
+
+import static com.codahale.metrics.MetricRegistry.name;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
+
+/**
+ * A Jetty {@link org.eclipse.jetty.server.HttpChannel.Listener} implementation which records various metrics about
+ * underlying channel instance. Unlike {@link InstrumentedHandler} that uses internal API, this class should be
+ * future proof. To install it, just add instance of this class to {@link org.eclipse.jetty.server.Connector} as bean.
+ *
+ * @since TBD
+ */
+public class InstrumentedHttpChannelListener
+    implements Listener
+{
+    private static final String START_ATTR = InstrumentedHttpChannelListener.class.getName() + ".start";
+    private static final Set<ResponseMeteredLevel> COARSE_METER_LEVELS = EnumSet.of(COARSE, ALL);
+    private static final Set<ResponseMeteredLevel> DETAILED_METER_LEVELS = EnumSet.of(DETAILED, ALL);
+
+    private final MetricRegistry metricRegistry;
+
+    // the requests handled by this handler, excluding active
+    private final Timer requests;
+
+    // the number of dispatches seen by this handler, excluding active
+    private final Timer dispatches;
+
+    // the number of active requests
+    private final Counter activeRequests;
+
+    // the number of active dispatches
+    private final Counter activeDispatches;
+
+    // the number of requests currently suspended.
+    private final Counter activeSuspended;
+
+    // the number of requests that have been asynchronously dispatched
+    private final Meter asyncDispatches;
+
+    // the number of requests that expired while suspended
+    private final Meter asyncTimeouts;
+
+    private final ResponseMeteredLevel responseMeteredLevel;
+    private final List<Meter> responses;
+    private final Map<Integer, Meter> responseCodeMeters;
+    private final String prefix;
+    private final Timer getRequests;
+    private final Timer postRequests;
+    private final Timer headRequests;
+    private final Timer putRequests;
+    private final Timer deleteRequests;
+    private final Timer optionsRequests;
+    private final Timer traceRequests;
+    private final Timer connectRequests;
+    private final Timer moveRequests;
+    private final Timer otherRequests;
+
+    private final AsyncListener listener;
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry) {
+        this(registry, null, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param pref     the prefix to use for the metrics names
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry, String pref) {
+        this(registry, pref, COARSE);
+    }
+
+    /**
+     * Create a new instrumented handler using a given metrics registry.
+     *
+     * @param registry the registry for the metrics
+     * @param pref     the prefix to use for the metrics names
+     * @param responseMeteredLevel the level to determine individual/aggregate response codes that are instrumented
+     */
+    public InstrumentedHttpChannelListener(MetricRegistry registry, String pref, ResponseMeteredLevel responseMeteredLevel) {
+        this.metricRegistry = registry;
+
+        this.prefix = (pref == null) ? getClass().getName() : pref;
+
+        this.requests = metricRegistry.timer(name(prefix, "requests"));
+        this.dispatches = metricRegistry.timer(name(prefix, "dispatches"));
+
+        this.activeRequests = metricRegistry.counter(name(prefix, "active-requests"));
+        this.activeDispatches = metricRegistry.counter(name(prefix, "active-dispatches"));
+        this.activeSuspended = metricRegistry.counter(name(prefix, "active-suspended"));
+
+        this.asyncDispatches = metricRegistry.meter(name(prefix, "async-dispatches"));
+        this.asyncTimeouts = metricRegistry.meter(name(prefix, "async-timeouts"));
+
+        this.responseMeteredLevel = responseMeteredLevel;
+        this.responseCodeMeters = DETAILED_METER_LEVELS.contains(responseMeteredLevel) ? new ConcurrentHashMap<>() : Collections.emptyMap();
+        this.responses = COARSE_METER_LEVELS.contains(responseMeteredLevel) ?
+                Collections.unmodifiableList(Arrays.asList(
+                        registry.meter(name(prefix, "1xx-responses")), // 1xx
+                        registry.meter(name(prefix, "2xx-responses")), // 2xx
+                        registry.meter(name(prefix, "3xx-responses")), // 3xx
+                        registry.meter(name(prefix, "4xx-responses")), // 4xx
+                        registry.meter(name(prefix, "5xx-responses"))  // 5xx
+                )) : Collections.emptyList();
+
+        this.getRequests = metricRegistry.timer(name(prefix, "get-requests"));
+        this.postRequests = metricRegistry.timer(name(prefix, "post-requests"));
+        this.headRequests = metricRegistry.timer(name(prefix, "head-requests"));
+        this.putRequests = metricRegistry.timer(name(prefix, "put-requests"));
+        this.deleteRequests = metricRegistry.timer(name(prefix, "delete-requests"));
+        this.optionsRequests = metricRegistry.timer(name(prefix, "options-requests"));
+        this.traceRequests = metricRegistry.timer(name(prefix, "trace-requests"));
+        this.connectRequests = metricRegistry.timer(name(prefix, "connect-requests"));
+        this.moveRequests = metricRegistry.timer(name(prefix, "move-requests"));
+        this.otherRequests = metricRegistry.timer(name(prefix, "other-requests"));
+
+        metricRegistry.register(name(prefix, "percent-4xx-1m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getOneMinuteRate(),
+                    requests.getOneMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-4xx-5m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getFiveMinuteRate(),
+                    requests.getFiveMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-4xx-15m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(3).getFifteenMinuteRate(),
+                    requests.getFifteenMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-1m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(4).getOneMinuteRate(),
+                    requests.getOneMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-5m"), new RatioGauge() {
+            @Override
+            protected Ratio getRatio() {
+                return Ratio.of(responses.get(4).getFiveMinuteRate(),
+                    requests.getFiveMinuteRate());
+            }
+        });
+
+        metricRegistry.register(name(prefix, "percent-5xx-15m"), new RatioGauge() {
+            @Override
+            public Ratio getRatio() {
+                return Ratio.of(responses.get(4).getFifteenMinuteRate(),
+                    requests.getFifteenMinuteRate());
+            }
+        });
+
+        this.listener = new AsyncAttachingListener();
+    }
+
+    @Override
+    public void onRequestBegin(final Request request) {
+
+    }
+
+    @Override
+    public void onBeforeDispatch(final Request request) {
+        before(request);
+    }
+
+    @Override
+    public void onDispatchFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onAfterDispatch(final Request request) {
+        after(request);
+    }
+
+    @Override
+    public void onRequestContent(final Request request, final ByteBuffer content) {
+
+    }
+
+    @Override
+    public void onRequestContentEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestTrailers(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onRequestFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onResponseBegin(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseCommit(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseContent(final Request request, final ByteBuffer content) {
+
+    }
+
+    @Override
+    public void onResponseEnd(final Request request) {
+
+    }
+
+    @Override
+    public void onResponseFailure(final Request request, final Throwable failure) {
+
+    }
+
+    @Override
+    public void onComplete(final Request request) {
+
+    }
+
+    private void before(final Request request) {
+        activeDispatches.inc();
+
+        final long start;
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isInitial()) {
+            // new request
+            activeRequests.inc();
+            start = request.getTimeStamp();
+            state.addListener(listener);
+        } else {
+            // resumed request
+            start = System.currentTimeMillis();
+            activeSuspended.dec();
+            if (state.isAsyncStarted()) {
+                asyncDispatches.mark();
+            }
+        }
+        request.setAttribute(START_ATTR, start);
+    }
+
+    private void after(final Request request) {
+        final long start = (long) request.getAttribute(START_ATTR);
+        final long now = System.currentTimeMillis();
+        final long dispatched = now - start;
+
+        activeDispatches.dec();
+        dispatches.update(dispatched, TimeUnit.MILLISECONDS);
+
+        final HttpChannelState state = request.getHttpChannelState();
+        if (state.isSuspended()) {
+            activeSuspended.inc();
+        } else if (state.isInitial()) {
+            updateResponses(request, request.getResponse(), start, request.isHandled());
+        }
+        // else onCompletion will handle it.
+    }
+
+    private void updateResponses(HttpServletRequest request, HttpServletResponse response, long start, boolean isHandled) {
+        if (isHandled) {
+            mark(response.getStatus());
+        } else {
+            mark(404); // will end up with a 404 response sent by HttpChannel.handle
+        }
+        activeRequests.dec();
+        final long elapsedTime = System.currentTimeMillis() - start;
+        requests.update(elapsedTime, TimeUnit.MILLISECONDS);
+        requestTimer(request.getMethod()).update(elapsedTime, TimeUnit.MILLISECONDS);
+    }
+
+    private void mark(int statusCode) {
+        if (DETAILED_METER_LEVELS.contains(responseMeteredLevel)) {
+            getResponseCodeMeter(statusCode).mark();
+        }
+
+        if (COARSE_METER_LEVELS.contains(responseMeteredLevel)) {
+            final int responseStatus = statusCode / 100;
+            if (responseStatus >= 1 && responseStatus <= 5) {
+                responses.get(responseStatus - 1).mark();
+            }
+        }
+    }
+
+    private Meter getResponseCodeMeter(int statusCode) {
+        return responseCodeMeters
+                .computeIfAbsent(statusCode, sc -> metricRegistry
+                        .meter(name(prefix, String.format("%d-responses", sc))));
+    }
+
+    private Timer requestTimer(String method) {
+        final HttpMethod m = HttpMethod.fromString(method);
+        if (m == null) {
+            return otherRequests;
+        } else {
+            switch (m) {
+                case GET:
+                    return getRequests;
+                case POST:
+                    return postRequests;
+                case PUT:
+                    return putRequests;
+                case HEAD:
+                    return headRequests;
+                case DELETE:
+                    return deleteRequests;
+                case OPTIONS:
+                    return optionsRequests;
+                case TRACE:
+                    return traceRequests;
+                case CONNECT:
+                    return connectRequests;
+                case MOVE:
+                    return moveRequests;
+                default:
+                    return otherRequests;
+            }
+        }
+    }
+
+    private class AsyncAttachingListener implements AsyncListener {
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+            event.getAsyncContext().addListener(new InstrumentedAsyncListener());
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {}
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {}
+    };
+
+    private class InstrumentedAsyncListener implements AsyncListener {
+        private final long startTime;
+
+        InstrumentedAsyncListener() {
+            this.startTime = System.currentTimeMillis();
+        }
+
+        @Override
+        public void onTimeout(AsyncEvent event) throws IOException {
+            asyncTimeouts.mark();
+        }
+
+        @Override
+        public void onStartAsync(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onError(AsyncEvent event) throws IOException {
+        }
+
+        @Override
+        public void onComplete(AsyncEvent event) throws IOException {
+            final AsyncContextState state = (AsyncContextState) event.getAsyncContext();
+            final HttpServletRequest request = (HttpServletRequest) state.getRequest();
+            final HttpServletResponse response = (HttpServletResponse) state.getResponse();
+            updateResponses(request, response, startTime, true);
+            if (!state.getHttpChannelState().isSuspended()) {
+                activeSuspended.dec();
+            }
+        }
+    }
+}
diff --git a/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPool.java b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPool.java
index ca79b60..d3889ca 100644
--- a/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPool.java
+++ b/metrics-jetty9/src/main/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPool.java
@@ -1,6 +1,5 @@
 package com.codahale.metrics.jetty9;
 
-import com.codahale.metrics.Gauge;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.RatioGauge;
 import org.eclipse.jetty.util.annotation.Name;
@@ -11,6 +10,12 @@ import java.util.concurrent.BlockingQueue;
 import static com.codahale.metrics.MetricRegistry.name;
 
 public class InstrumentedQueuedThreadPool extends QueuedThreadPool {
+    private static final String NAME_UTILIZATION = "utilization";
+    private static final String NAME_UTILIZATION_MAX = "utilization-max";
+    private static final String NAME_SIZE = "size";
+    private static final String NAME_JOBS = "jobs";
+    private static final String NAME_JOBS_QUEUE_UTILIZATION = "jobs-queue-utilization";
+
     private final MetricRegistry metricRegistry;
     private String prefix;
 
@@ -67,33 +72,47 @@ public class InstrumentedQueuedThreadPool extends QueuedThreadPool {
     protected void doStart() throws Exception {
         super.doStart();
 
-        final String prefix = this.prefix == null ? name(QueuedThreadPool.class, getName()) : name(this.prefix, getName());
+        final String prefix = getMetricPrefix();
 
-        metricRegistry.register(name(prefix, "utilization"), new RatioGauge() {
+        metricRegistry.register(name(prefix, NAME_UTILIZATION), new RatioGauge() {
             @Override
             protected Ratio getRatio() {
                 return Ratio.of(getThreads() - getIdleThreads(), getThreads());
             }
         });
-        metricRegistry.register(name(prefix, "utilization-max"), new RatioGauge() {
+        metricRegistry.register(name(prefix, NAME_UTILIZATION_MAX), new RatioGauge() {
             @Override
             protected Ratio getRatio() {
                 return Ratio.of(getThreads() - getIdleThreads(), getMaxThreads());
             }
         });
-        metricRegistry.register(name(prefix, "size"), new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return getThreads();
-            }
-        });
-        metricRegistry.register(name(prefix, "jobs"), new Gauge<Integer>() {
+        metricRegistry.registerGauge(name(prefix, NAME_SIZE), this::getThreads);
+        // This assumes the QueuedThreadPool is using a BlockingArrayQueue or
+        // ArrayBlockingQueue for its queue, and is therefore a constant-time operation.
+        metricRegistry.registerGauge(name(prefix, NAME_JOBS), () -> getQueue().size());
+        metricRegistry.register(name(prefix, NAME_JOBS_QUEUE_UTILIZATION), new RatioGauge() {
             @Override
-            public Integer getValue() {
-                // This assumes the QueuedThreadPool is using a BlockingArrayQueue or
-                // ArrayBlockingQueue for its queue, and is therefore a constant-time operation.
-                return getQueue().size();
+            protected Ratio getRatio() {
+                BlockingQueue<Runnable> queue = getQueue();
+                return Ratio.of(queue.size(), queue.size() + queue.remainingCapacity());
             }
         });
     }
+
+    @Override
+    protected void doStop() throws Exception {
+        final String prefix = getMetricPrefix();
+
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION));
+        metricRegistry.remove(name(prefix, NAME_UTILIZATION_MAX));
+        metricRegistry.remove(name(prefix, NAME_SIZE));
+        metricRegistry.remove(name(prefix, NAME_JOBS));
+        metricRegistry.remove(name(prefix, NAME_JOBS_QUEUE_UTILIZATION));
+
+        super.doStop();
+    }
+
+    private String getMetricPrefix() {
+        return this.prefix == null ? name(QueuedThreadPool.class, getName()) : name(this.prefix, getName());
+    }
 }
diff --git a/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactoryTest.java b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactoryTest.java
index 5d825dd..d06535d 100644
--- a/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactoryTest.java
+++ b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedConnectionFactoryTest.java
@@ -1,5 +1,6 @@
 package com.codahale.metrics.jetty9;
 
+import com.codahale.metrics.Counter;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.Timer;
 import org.eclipse.jetty.client.HttpClient;
@@ -19,7 +20,6 @@ import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.io.PrintWriter;
 
-import static com.codahale.metrics.MetricRegistry.name;
 import static org.assertj.core.api.Assertions.assertThat;
 
 public class InstrumentedConnectionFactoryTest {
@@ -27,7 +27,8 @@ public class InstrumentedConnectionFactoryTest {
     private final Server server = new Server();
     private final ServerConnector connector =
             new ServerConnector(server, new InstrumentedConnectionFactory(new HttpConnectionFactory(),
-                                                                          registry.timer("http.connections")));
+                    registry.timer("http.connections"),
+                    registry.counter("http.active-connections")));
     private final HttpClient client = new HttpClient();
 
     @Before
@@ -66,8 +67,27 @@ public class InstrumentedConnectionFactoryTest {
 
         Thread.sleep(100); // make sure the connection is closed
 
-        final Timer timer = registry.timer(name("http.connections"));
+        final Timer timer = registry.timer(MetricRegistry.name("http.connections"));
         assertThat(timer.getCount())
                 .isEqualTo(1);
     }
+
+    @Test
+    public void instrumentsActiveConnections() throws Exception {
+        final Counter counter = registry.counter("http.active-connections");
+
+        final ContentResponse response = client.GET("http://localhost:" + connector.getLocalPort() + "/hello");
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+
+        assertThat(counter.getCount())
+                .isEqualTo(1);
+
+        client.stop(); // close the connection
+
+        Thread.sleep(100); // make sure the connection is closed
+
+        assertThat(counter.getCount())
+                .isEqualTo(0);
+    }
 }
diff --git a/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedHandlerTest.java b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedHandlerTest.java
index 407bbf0..12dd977 100644
--- a/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedHandlerTest.java
+++ b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedHandlerTest.java
@@ -9,6 +9,7 @@ import org.eclipse.jetty.server.ServerConnector;
 import org.eclipse.jetty.server.handler.AbstractHandler;
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Test;
 
 import javax.servlet.AsyncContext;
@@ -17,19 +18,22 @@ import javax.servlet.ServletOutputStream;
 import javax.servlet.WriteListener;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.locks.LockSupport;
 
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.COARSE;
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.DETAILED;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
 
 public class InstrumentedHandlerTest {
     private final HttpClient client = new HttpClient();
     private final MetricRegistry registry = new MetricRegistry();
     private final Server server = new Server();
     private final ServerConnector connector = new ServerConnector(server);
-    private final InstrumentedHandler handler = new InstrumentedHandler(registry);
+    private final InstrumentedHandler handler = new InstrumentedHandler(registry, null, ALL);
 
     @Before
     public void setUp() throws Exception {
@@ -50,52 +54,54 @@ public class InstrumentedHandlerTest {
     @Test
     public void hasAName() throws Exception {
         assertThat(handler.getName())
-                .isEqualTo("handler");
+            .isEqualTo("handler");
     }
 
     @Test
-    public void createsMetricsForTheHandler() throws Exception {
+    public void createsAndRemovesMetricsForTheHandler() throws Exception {
         final ContentResponse response = client.GET(uri("/hello"));
 
         assertThat(response.getStatus())
-                .isEqualTo(404);
+            .isEqualTo(404);
 
         assertThat(registry.getNames())
-                .containsOnly(
-                        MetricRegistry.name(TestHandler.class,"handler.1xx-responses"),
-                        MetricRegistry.name(TestHandler.class,"handler.2xx-responses"),
-                        MetricRegistry.name(TestHandler.class,"handler.3xx-responses"),
-                        MetricRegistry.name(TestHandler.class,"handler.4xx-responses"),
-                        MetricRegistry.name(TestHandler.class,"handler.5xx-responses"),
-                        MetricRegistry.name(TestHandler.class,"handler.percent-4xx-1m"),
-                        MetricRegistry.name(TestHandler.class,"handler.percent-4xx-5m"),
-                        MetricRegistry.name(TestHandler.class,"handler.percent-4xx-15m"),
-                        MetricRegistry.name(TestHandler.class,"handler.percent-5xx-1m"),
-                        MetricRegistry.name(TestHandler.class,"handler.percent-5xx-5m"),
-                        MetricRegistry.name(TestHandler.class,"handler.percent-5xx-15m"),
-                        MetricRegistry.name(TestHandler.class,"handler.requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.active-suspended"),
-                        MetricRegistry.name(TestHandler.class,"handler.async-dispatches"),
-                        MetricRegistry.name(TestHandler.class,"handler.async-timeouts"),
-                        MetricRegistry.name(TestHandler.class,"handler.get-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.put-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.active-dispatches"),
-                        MetricRegistry.name(TestHandler.class,"handler.trace-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.other-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.connect-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.dispatches"),
-                        MetricRegistry.name(TestHandler.class,"handler.head-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.post-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.options-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.active-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.delete-requests"),
-                        MetricRegistry.name(TestHandler.class,"handler.move-requests")
-                );
-
-        assertThat(registry.getMeters().get(metricName() + ".4xx-responses")
-                .getCount()).isGreaterThan(0L);
-    }
+            .containsOnly(
+                MetricRegistry.name(TestHandler.class, "handler.1xx-responses"),
+                MetricRegistry.name(TestHandler.class, "handler.2xx-responses"),
+                MetricRegistry.name(TestHandler.class, "handler.3xx-responses"),
+                MetricRegistry.name(TestHandler.class, "handler.4xx-responses"),
+                MetricRegistry.name(TestHandler.class, "handler.404-responses"),
+                MetricRegistry.name(TestHandler.class, "handler.5xx-responses"),
+                MetricRegistry.name(TestHandler.class, "handler.percent-4xx-1m"),
+                MetricRegistry.name(TestHandler.class, "handler.percent-4xx-5m"),
+                MetricRegistry.name(TestHandler.class, "handler.percent-4xx-15m"),
+                MetricRegistry.name(TestHandler.class, "handler.percent-5xx-1m"),
+                MetricRegistry.name(TestHandler.class, "handler.percent-5xx-5m"),
+                MetricRegistry.name(TestHandler.class, "handler.percent-5xx-15m"),
+                MetricRegistry.name(TestHandler.class, "handler.requests"),
+                MetricRegistry.name(TestHandler.class, "handler.active-suspended"),
+                MetricRegistry.name(TestHandler.class, "handler.async-dispatches"),
+                MetricRegistry.name(TestHandler.class, "handler.async-timeouts"),
+                MetricRegistry.name(TestHandler.class, "handler.get-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.put-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.active-dispatches"),
+                MetricRegistry.name(TestHandler.class, "handler.trace-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.other-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.connect-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.dispatches"),
+                MetricRegistry.name(TestHandler.class, "handler.head-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.post-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.options-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.active-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.delete-requests"),
+                MetricRegistry.name(TestHandler.class, "handler.move-requests")
+            );
 
+        server.stop();
+
+        assertThat(registry.getNames())
+                .isEmpty();
+    }
 
     @Test
     public void responseTimesAreRecordedForBlockingResponses() throws Exception {
@@ -103,32 +109,61 @@ public class InstrumentedHandlerTest {
         final ContentResponse response = client.GET(uri("/blocking"));
 
         assertThat(response.getStatus())
-                .isEqualTo(200);
+            .isEqualTo(200);
 
         assertResponseTimesValid();
     }
 
     @Test
+    public void doStopDoesNotThrowNPE() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, null, ALL);
+        handler.setHandler(new TestHandler());
+
+        assertThatCode(handler::doStop).doesNotThrowAnyException();
+    }
+
+    @Test
+    public void gaugesAreRegisteredWithResponseMeteredLevelCoarse() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, "coarse", COARSE);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).containsKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    public void gaugesAreNotRegisteredWithResponseMeteredLevelDetailed() throws Exception {
+        InstrumentedHandler handler = new InstrumentedHandler(registry, "detailed", DETAILED);
+        handler.setHandler(new TestHandler());
+        handler.setName("handler");
+        handler.doStart();
+        assertThat(registry.getGauges()).doesNotContainKey("coarse.handler.percent-4xx-1m");
+    }
+
+    @Test
+    @Ignore("flaky on virtual machines")
     public void responseTimesAreRecordedForAsyncResponses() throws Exception {
 
         final ContentResponse response = client.GET(uri("/async"));
 
         assertThat(response.getStatus())
-                .isEqualTo(200);
+            .isEqualTo(200);
 
         assertResponseTimesValid();
     }
 
     private void assertResponseTimesValid() {
         assertThat(registry.getMeters().get(metricName() + ".2xx-responses")
+            .getCount()).isGreaterThan(0L);
+        assertThat(registry.getMeters().get(metricName() + ".200-responses")
                 .getCount()).isGreaterThan(0L);
 
 
         assertThat(registry.getTimers().get(metricName() + ".get-requests")
-                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+            .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
 
         assertThat(registry.getTimers().get(metricName() + ".requests")
-                .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
+            .getSnapshot().getMedian()).isGreaterThan(0.0).isLessThan(TimeUnit.SECONDS.toNanos(1));
     }
 
     private String uri(String path) {
@@ -141,59 +176,71 @@ public class InstrumentedHandlerTest {
 
     /**
      * test handler.
-     *
+     * <p>
      * Supports
-     *
+     * <p>
      * /blocking - uses the standard servlet api
      * /async - uses the 3.1 async api to complete the request
-     *
+     * <p>
      * all other requests will return 404
      */
     private static class TestHandler extends AbstractHandler {
         @Override
         public void handle(
-                String path,
-                Request request,
-                final HttpServletRequest httpServletRequest,
-                final HttpServletResponse httpServletResponse
+            String path,
+            Request request,
+            final HttpServletRequest httpServletRequest,
+            final HttpServletResponse httpServletResponse
         ) throws IOException, ServletException {
-            if (path.equals("/blocking")) {
-                request.setHandled(true);
-                LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
-                httpServletResponse.setStatus(200);
-                httpServletResponse.setContentType("text/plain");
-                httpServletResponse.getWriter().write("some content from the blocking request\n");
-            } else if (path.equals("/async")) {
-                request.setHandled(true);
-                final AsyncContext context = request.startAsync();
-                Thread t = new Thread() {
-                    public void run() {
-                        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
+            switch (path) {
+                case "/blocking":
+                    request.setHandled(true);
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    httpServletResponse.setStatus(200);
+                    httpServletResponse.setContentType("text/plain");
+                    httpServletResponse.getWriter().write("some content from the blocking request\n");
+                    break;
+                case "/async":
+                    request.setHandled(true);
+                    final AsyncContext context = request.startAsync();
+                    Thread t = new Thread(() -> {
+                        try {
+                            Thread.sleep(100);
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                        }
                         httpServletResponse.setStatus(200);
                         httpServletResponse.setContentType("text/plain");
                         final ServletOutputStream servletOutputStream;
                         try {
                             servletOutputStream = httpServletResponse.getOutputStream();
                             servletOutputStream.setWriteListener(
-                                    new WriteListener() {
-                                        @Override
-                                        public void onWritePossible() throws IOException {
-                                            servletOutputStream.write("some content from the async\n".getBytes());
-                                            context.complete();
-                                        }
-
-                                        @Override
-                                        public void onError(Throwable throwable) {
-                                            context.complete();
-                                        }
+                                new WriteListener() {
+                                    @Override
+                                    public void onWritePossible() throws IOException {
+                                        servletOutputStream.write("some content from the async\n"
+                                            .getBytes(StandardCharsets.UTF_8));
+                                        context.complete();
                                     }
+
+                                    @Override
+                                    public void onError(Throwable throwable) {
+                                        context.complete();
+                                    }
+                                }
                             );
                         } catch (IOException e) {
                             context.complete();
                         }
-                    }
-                };
-                t.start();
+                    });
+                    t.start();
+                    break;
+                 default:
+                     break;
             }
         }
     }
diff --git a/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedHttpChannelListenerTest.java b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedHttpChannelListenerTest.java
new file mode 100644
index 0000000..2bddebe
--- /dev/null
+++ b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedHttpChannelListenerTest.java
@@ -0,0 +1,212 @@
+package com.codahale.metrics.jetty9;
+
+import com.codahale.metrics.MetricRegistry;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static com.codahale.metrics.annotation.ResponseMeteredLevel.ALL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class InstrumentedHttpChannelListenerTest {
+    private final HttpClient client = new HttpClient();
+    private final Server server = new Server();
+    private final ServerConnector connector = new ServerConnector(server);
+    private final TestHandler handler = new TestHandler();
+    private MetricRegistry registry;
+
+    @Before
+    public void setUp() throws Exception {
+        registry = new MetricRegistry();
+        connector.addBean(new InstrumentedHttpChannelListener(registry, MetricRegistry.name(TestHandler.class, "handler"), ALL));
+        server.addConnector(connector);
+        server.setHandler(handler);
+        server.start();
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        server.stop();
+        client.stop();
+    }
+
+    @Test
+    public void createsMetricsForTheHandler() throws Exception {
+        final ContentResponse response = client.GET(uri("/hello"));
+
+        assertThat(response.getStatus())
+            .isEqualTo(404);
+
+        assertThat(registry.getNames())
+            .containsOnly(
+                metricName("1xx-responses"),
+                metricName("2xx-responses"),
+                metricName("3xx-responses"),
+                metricName("404-responses"),
+                metricName("4xx-responses"),
+                metricName("5xx-responses"),
+                metricName("percent-4xx-1m"),
+                metricName("percent-4xx-5m"),
+                metricName("percent-4xx-15m"),
+                metricName("percent-5xx-1m"),
+                metricName("percent-5xx-5m"),
+                metricName("percent-5xx-15m"),
+                metricName("requests"),
+                metricName("active-suspended"),
+                metricName("async-dispatches"),
+                metricName("async-timeouts"),
+                metricName("get-requests"),
+                metricName("put-requests"),
+                metricName("active-dispatches"),
+                metricName("trace-requests"),
+                metricName("other-requests"),
+                metricName("connect-requests"),
+                metricName("dispatches"),
+                metricName("head-requests"),
+                metricName("post-requests"),
+                metricName("options-requests"),
+                metricName("active-requests"),
+                metricName("delete-requests"),
+                metricName("move-requests")
+            );
+    }
+
+
+    @Test
+    public void responseTimesAreRecordedForBlockingResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/blocking"));
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.getMediaType()).isEqualTo("text/plain");
+        assertThat(response.getContentAsString()).isEqualTo("some content from the blocking request");
+
+        assertResponseTimesValid();
+    }
+
+    @Test
+    public void responseTimesAreRecordedForAsyncResponses() throws Exception {
+
+        final ContentResponse response = client.GET(uri("/async"));
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.getMediaType()).isEqualTo("text/plain");
+        assertThat(response.getContentAsString()).isEqualTo("some content from the async");
+
+        assertResponseTimesValid();
+    }
+
+    private void assertResponseTimesValid() {
+        try {
+            Thread.sleep(100);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+
+        assertThat(registry.getMeters().get(metricName("2xx-responses"))
+            .getCount()).isPositive();
+        assertThat(registry.getMeters().get(metricName("200-responses"))
+                .getCount()).isPositive();
+
+        assertThat(registry.getTimers().get(metricName("get-requests"))
+            .getSnapshot().getMedian()).isPositive();
+
+        assertThat(registry.getTimers().get(metricName("requests"))
+            .getSnapshot().getMedian()).isPositive();
+    }
+
+    private String uri(String path) {
+        return "http://localhost:" + connector.getLocalPort() + path;
+    }
+
+    private String metricName(String metricName) {
+        return MetricRegistry.name(TestHandler.class.getName(), "handler", metricName);
+    }
+
+    /**
+     * test handler.
+     * <p>
+     * Supports
+     * <p>
+     * /blocking - uses the standard servlet api
+     * /async - uses the 3.1 async api to complete the request
+     * <p>
+     * all other requests will return 404
+     */
+    private static class TestHandler extends AbstractHandler {
+        @Override
+        public void handle(
+            String path,
+            Request request,
+            final HttpServletRequest httpServletRequest,
+            final HttpServletResponse httpServletResponse) throws IOException {
+            switch (path) {
+                case "/blocking":
+                    request.setHandled(true);
+                    httpServletResponse.setStatus(200);
+                    httpServletResponse.setContentType("text/plain");
+                    httpServletResponse.getWriter().write("some content from the blocking request");
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        httpServletResponse.setStatus(500);
+                        Thread.currentThread().interrupt();
+                    }
+                    break;
+                case "/async":
+                    request.setHandled(true);
+                    final AsyncContext context = request.startAsync();
+                    Thread t = new Thread(() -> {
+                        httpServletResponse.setStatus(200);
+                        httpServletResponse.setContentType("text/plain");
+                        try {
+                            Thread.sleep(100);
+                        } catch (InterruptedException e) {
+                            httpServletResponse.setStatus(500);
+                            Thread.currentThread().interrupt();
+                        }
+                        final ServletOutputStream servletOutputStream;
+                        try {
+                            servletOutputStream = httpServletResponse.getOutputStream();
+                            servletOutputStream.setWriteListener(
+                                new WriteListener() {
+                                    @Override
+                                    public void onWritePossible() throws IOException {
+                                        servletOutputStream.write("some content from the async"
+                                            .getBytes(StandardCharsets.UTF_8));
+                                        context.complete();
+                                    }
+
+                                    @Override
+                                    public void onError(Throwable throwable) {
+                                        context.complete();
+                                    }
+                                }
+                            );
+                        } catch (IOException e) {
+                            context.complete();
+                        }
+                    });
+                    t.start();
+                    break;
+                 default:
+                     break;
+            }
+        }
+    }
+}
diff --git a/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPoolTest.java b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPoolTest.java
index 2b4ed04..2b4ddcc 100644
--- a/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPoolTest.java
+++ b/metrics-jetty9/src/test/java/com/codahale/metrics/jetty9/InstrumentedQueuedThreadPoolTest.java
@@ -1,49 +1,49 @@
 package com.codahale.metrics.jetty9;
 
-import static org.hamcrest.CoreMatchers.startsWith;
-import static org.junit.Assert.*;
-import static org.mockito.Matchers.any;
-import static org.mockito.Mockito.atLeastOnce;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-
-import com.codahale.metrics.Metric;
 import com.codahale.metrics.MetricRegistry;
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
-import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
-import org.mockito.ArgumentCaptor;
+
+import static org.assertj.core.api.Assertions.assertThat;
 
 public class InstrumentedQueuedThreadPoolTest {
     private static final String PREFIX = "prefix";
 
-    private final MetricRegistry metricRegistry = mock(MetricRegistry.class);
-    private final InstrumentedQueuedThreadPool iqtp = new InstrumentedQueuedThreadPool(metricRegistry);
-    private final ArgumentCaptor<String> metricNameCaptor = ArgumentCaptor.forClass(String.class);
+    private MetricRegistry metricRegistry;
+    private InstrumentedQueuedThreadPool iqtp;
 
-    @After
-    public void tearDown() throws Exception {
-        iqtp.stop();
+    @Before
+    public void setUp() {
+        metricRegistry = new MetricRegistry();
+        iqtp = new InstrumentedQueuedThreadPool(metricRegistry);
     }
 
     @Test
-    public void customMetricsPrefix() throws Exception{
+    public void customMetricsPrefix() throws Exception {
         iqtp.setPrefix(PREFIX);
-        iqtp.doStart();
+        iqtp.start();
 
-        verify(metricRegistry, atLeastOnce()).register(metricNameCaptor.capture(), any(Metric.class));
-        String metricName = metricNameCaptor.getValue();
-        assertThat("Custom metric's prefix doesn't match", metricName, startsWith(PREFIX));
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("Custom metrics prefix doesn't match")
+                .allSatisfy(name -> assertThat(name).startsWith(PREFIX));
 
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
     }
 
     @Test
-    public void metricsPrefixBackwardCompatible() throws Exception{
-        iqtp.doStart();
+    public void metricsPrefixBackwardCompatible() throws Exception {
+        iqtp.start();
+        assertThat(metricRegistry.getNames())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .allSatisfy(name -> assertThat(name).startsWith(QueuedThreadPool.class.getName()));
 
-        verify(metricRegistry, atLeastOnce()).register(metricNameCaptor.capture(), any(Metric.class));
-        String metricName = metricNameCaptor.getValue();
-        assertThat("The default metrics prefix was changed", metricName, startsWith(QueuedThreadPool.class.getName()));
+        iqtp.stop();
+        assertThat(metricRegistry.getMetrics())
+                .overridingErrorMessage("The default metrics prefix was changed")
+                .isEmpty();
     }
-
 }
diff --git a/metrics-jmx/pom.xml b/metrics-jmx/pom.xml
new file mode 100644
index 0000000..efab0ce
--- /dev/null
+++ b/metrics-jmx/pom.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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">
+    <parent>
+        <artifactId>metrics-parent</artifactId>
+        <groupId>io.dropwizard.metrics</groupId>
+        <version>4.2.25</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>metrics-jmx</artifactId>
+    <name>Metrics Integration with JMX</name>
+    <packaging>bundle</packaging>
+    <description>
+        A set of classes which allow you to report metrics via JMX.
+    </description>
+
+    <properties>
+        <javaModuleName>com.codahale.metrics.jmx</javaModuleName>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-jmx/src/main/java/com/codahale/metrics/jmx/DefaultObjectNameFactory.java b/metrics-jmx/src/main/java/com/codahale/metrics/jmx/DefaultObjectNameFactory.java
new file mode 100644
index 0000000..c8ad1c2
--- /dev/null
+++ b/metrics-jmx/src/main/java/com/codahale/metrics/jmx/DefaultObjectNameFactory.java
@@ -0,0 +1,69 @@
+package com.codahale.metrics.jmx;
+
+import java.util.Hashtable;
+
+import javax.management.MalformedObjectNameException;
+import javax.management.ObjectName;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DefaultObjectNameFactory implements ObjectNameFactory {
+
+    private static final char[] QUOTABLE_CHARS = new char[] {',', '=', ':', '"'};
+    private static final Logger LOGGER = LoggerFactory.getLogger(JmxReporter.class);
+
+    @Override
+    public ObjectName createName(String type, String domain, String name) {
+        try {
+            ObjectName objectName;
+            Hashtable<String, String> properties = new Hashtable<>();
+
+            properties.put("name", name);
+            properties.put("type", type);
+            objectName = new ObjectName(domain, properties);
+
+            /*
+             * The only way we can find out if we need to quote the properties is by
+             * checking an ObjectName that we've constructed.
+             */
+            if (objectName.isDomainPattern()) {
+                domain = ObjectName.quote(domain);
+            }
+            if (objectName.isPropertyValuePattern("name") || shouldQuote(objectName.getKeyProperty("name"))) {
+                properties.put("name", ObjectName.quote(name));
+            }
+            if (objectName.isPropertyValuePattern("type") || shouldQuote(objectName.getKeyProperty("type"))) {
+                properties.put("type", ObjectName.quote(type));
+            }
+            objectName = new ObjectName(domain, properties);
+
+            return objectName;
+        } catch (MalformedObjectNameException e) {
+            try {
+                return new ObjectName(domain, "name", ObjectName.quote(name));
+            } catch (MalformedObjectNameException e1) {
+                LOGGER.warn("Unable to register {} {}", type, name, e1);
+                throw new RuntimeException(e1);
+            }
+        }
+    }
+
+    /**
+     * Determines whether the value requires quoting.
+     * According to the {@link ObjectName} documentation, values can be quoted or unquoted. Unquoted
+     * values may not contain any of the characters comma, equals, colon, or quote.
+     *
+     * @param value a value to test
+     * @return true when it requires quoting, false otherwise
+     */
+    private boolean shouldQuote(final String value) {
+        for (char quotableChar : QUOTABLE_CHARS) {
+            if (value.indexOf(quotableChar) != -1) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+}
diff --git a/metrics-core/src/main/java/com/codahale/metrics/JmxReporter.java b/metrics-jmx/src/main/java/com/codahale/metrics/jmx/JmxReporter.java
similarity index 93%
rename from metrics-core/src/main/java/com/codahale/metrics/JmxReporter.java
rename to metrics-jmx/src/main/java/com/codahale/metrics/jmx/JmxReporter.java
index 2be42f4..8f76071 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/JmxReporter.java
+++ b/metrics-jmx/src/main/java/com/codahale/metrics/jmx/JmxReporter.java
@@ -1,9 +1,25 @@
-package com.codahale.metrics;
-
+package com.codahale.metrics.jmx;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.Metered;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.MetricRegistryListener;
+import com.codahale.metrics.Reporter;
+import com.codahale.metrics.Timer;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import javax.management.*;
+import javax.management.InstanceAlreadyExistsException;
+import javax.management.InstanceNotFoundException;
+import javax.management.JMException;
+import javax.management.MBeanRegistrationException;
+import javax.management.MBeanServer;
+import javax.management.ObjectInstance;
+import javax.management.ObjectName;
 import java.io.Closeable;
 import java.lang.management.ManagementFactory;
 import java.util.Collections;
@@ -54,7 +70,7 @@ public class JmxReporter implements Reporter, Closeable {
         /**
          * Register MBeans with the given {@link MBeanServer}.
          *
-         * @param mBeanServer     an {@link MBeanServer}
+         * @param mBeanServer an {@link MBeanServer}
          * @return {@code this}
          */
         public Builder registerWith(MBeanServer mBeanServer) {
@@ -74,13 +90,13 @@ public class JmxReporter implements Reporter, Closeable {
         }
 
         public Builder createsObjectNamesWith(ObjectNameFactory onFactory) {
-        	if(onFactory == null) {
-        		throw new IllegalArgumentException("null objectNameFactory");
-        	}
-        	this.objectNameFactory = onFactory;
-        	return this;
+            if (onFactory == null) {
+                throw new IllegalArgumentException("null objectNameFactory");
+            }
+            this.objectNameFactory = onFactory;
+            return this;
         }
-        
+
         /**
          * Convert durations to the given time unit.
          *
@@ -138,8 +154,8 @@ public class JmxReporter implements Reporter, Closeable {
          */
         public JmxReporter build() {
             final MetricTimeUnits timeUnits = new MetricTimeUnits(rateUnit, durationUnit, specificRateUnits, specificDurationUnits);
-            if (mBeanServer==null) {
-            	mBeanServer = ManagementFactory.getPlatformMBeanServer();
+            if (mBeanServer == null) {
+                mBeanServer = ManagementFactory.getPlatformMBeanServer();
             }
             return new JmxReporter(mBeanServer, domain, registry, filter, timeUnits, objectNameFactory);
         }
@@ -147,13 +163,10 @@ public class JmxReporter implements Reporter, Closeable {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(JmxReporter.class);
 
-    // CHECKSTYLE:OFF
     @SuppressWarnings("UnusedDeclaration")
     public interface MetricMBean {
         ObjectName objectName();
     }
-    // CHECKSTYLE:ON
-
 
     private abstract static class AbstractBean implements MetricMBean {
         private final ObjectName objectName;
@@ -168,12 +181,11 @@ public class JmxReporter implements Reporter, Closeable {
         }
     }
 
-    // CHECKSTYLE:OFF
     @SuppressWarnings("UnusedDeclaration")
     public interface JmxGaugeMBean extends MetricMBean {
         Object getValue();
+        Number getNumber();
     }
-    // CHECKSTYLE:ON
 
     private static class JmxGauge extends AbstractBean implements JmxGaugeMBean {
         private final Gauge<?> metric;
@@ -187,14 +199,18 @@ public class JmxReporter implements Reporter, Closeable {
         public Object getValue() {
             return metric.getValue();
         }
+
+        @Override
+        public Number getNumber() {
+            Object value = metric.getValue();
+            return value instanceof Number ? (Number) value : 0;
+        }
     }
 
-    // CHECKSTYLE:OFF
     @SuppressWarnings("UnusedDeclaration")
     public interface JmxCounterMBean extends MetricMBean {
         long getCount();
     }
-    // CHECKSTYLE:ON
 
     private static class JmxCounter extends AbstractBean implements JmxCounterMBean {
         private final Counter metric;
@@ -210,7 +226,6 @@ public class JmxReporter implements Reporter, Closeable {
         }
     }
 
-    // CHECKSTYLE:OFF
     @SuppressWarnings("UnusedDeclaration")
     public interface JmxHistogramMBean extends MetricMBean {
         long getCount();
@@ -239,7 +254,6 @@ public class JmxReporter implements Reporter, Closeable {
 
         long getSnapshotSize();
     }
-    // CHECKSTYLE:ON
 
     private static class JmxHistogram implements JmxHistogramMBean {
         private final ObjectName objectName;
@@ -315,12 +329,12 @@ public class JmxReporter implements Reporter, Closeable {
             return metric.getSnapshot().getValues();
         }
 
+        @Override
         public long getSnapshotSize() {
             return metric.getSnapshot().size();
         }
     }
 
-    //CHECKSTYLE:OFF
     @SuppressWarnings("UnusedDeclaration")
     public interface JmxMeterMBean extends MetricMBean {
         long getCount();
@@ -335,7 +349,6 @@ public class JmxReporter implements Reporter, Closeable {
 
         String getRateUnit();
     }
-    //CHECKSTYLE:ON
 
     private static class JmxMeter extends AbstractBean implements JmxMeterMBean {
         private final Metered metric;
@@ -385,7 +398,6 @@ public class JmxReporter implements Reporter, Closeable {
         }
     }
 
-    // CHECKSTYLE:OFF
     @SuppressWarnings("UnusedDeclaration")
     public interface JmxTimerMBean extends JmxMeterMBean {
         double getMin();
@@ -409,9 +421,9 @@ public class JmxReporter implements Reporter, Closeable {
         double get999thPercentile();
 
         long[] values();
+
         String getDurationUnit();
     }
-    // CHECKSTYLE:ON
 
     static class JmxTimer extends JmxMeter implements JmxTimerMBean {
         private final Timer metric;
@@ -502,7 +514,7 @@ public class JmxReporter implements Reporter, Closeable {
             this.name = name;
             this.filter = filter;
             this.timeUnits = timeUnits;
-            this.registered = new ConcurrentHashMap<ObjectName, ObjectName>();
+            this.registered = new ConcurrentHashMap<>();
             this.objectNameFactory = objectNameFactory;
         }
 
@@ -692,11 +704,11 @@ public class JmxReporter implements Reporter, Closeable {
         }
 
         public TimeUnit durationFor(String name) {
-            return durationOverrides.containsKey(name) ? durationOverrides.get(name) : defaultDuration;
+            return durationOverrides.getOrDefault(name, defaultDuration);
         }
 
         public TimeUnit rateFor(String name) {
-            return rateOverrides.containsKey(name) ? rateOverrides.get(name) : defaultRate;
+            return rateOverrides.getOrDefault(name, defaultRate);
         }
     }
 
@@ -707,7 +719,7 @@ public class JmxReporter implements Reporter, Closeable {
                         String domain,
                         MetricRegistry registry,
                         MetricFilter filter,
-                        MetricTimeUnits timeUnits, 
+                        MetricTimeUnits timeUnits,
                         ObjectNameFactory objectNameFactory) {
         this.registry = registry;
         this.listener = new JmxListener(mBeanServer, domain, filter, timeUnits, objectNameFactory);
diff --git a/metrics-jmx/src/main/java/com/codahale/metrics/jmx/ObjectNameFactory.java b/metrics-jmx/src/main/java/com/codahale/metrics/jmx/ObjectNameFactory.java
new file mode 100644
index 0000000..72400b0
--- /dev/null
+++ b/metrics-jmx/src/main/java/com/codahale/metrics/jmx/ObjectNameFactory.java
@@ -0,0 +1,8 @@
+package com.codahale.metrics.jmx;
+
+import javax.management.ObjectName;
+
+public interface ObjectNameFactory {
+
+    ObjectName createName(String type, String domain, String name);
+}
diff --git a/metrics-jmx/src/test/java/com/codahale/metrics/jmx/DefaultObjectNameFactoryTest.java b/metrics-jmx/src/test/java/com/codahale/metrics/jmx/DefaultObjectNameFactoryTest.java
new file mode 100644
index 0000000..590ad74
--- /dev/null
+++ b/metrics-jmx/src/test/java/com/codahale/metrics/jmx/DefaultObjectNameFactoryTest.java
@@ -0,0 +1,33 @@
+package com.codahale.metrics.jmx;
+
+import org.junit.Test;
+
+import javax.management.ObjectName;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+public class DefaultObjectNameFactoryTest {
+
+    @Test
+    public void createsObjectNameWithDomainInInput() {
+        DefaultObjectNameFactory f = new DefaultObjectNameFactory();
+        ObjectName on = f.createName("type", "com.domain", "something.with.dots");
+        assertThat(on.getDomain()).isEqualTo("com.domain");
+    }
+
+    @Test
+    public void createsObjectNameWithNameAsKeyPropertyName() {
+        DefaultObjectNameFactory f = new DefaultObjectNameFactory();
+        ObjectName on = f.createName("type", "com.domain", "something.with.dots");
+        assertThat(on.getKeyProperty("name")).isEqualTo("something.with.dots");
+    }
+
+    @Test
+    public void createsObjectNameWithNameWithDisallowedUnquotedCharacters() {
+        DefaultObjectNameFactory f = new DefaultObjectNameFactory();
+        ObjectName on = f.createName("type", "com.domain", "something.with.quotes(\"ABcd\")");
+        assertThatCode(() -> new ObjectName(on.toString())).doesNotThrowAnyException();
+        assertThat(on.getKeyProperty("name")).isEqualTo("\"something.with.quotes(\\\"ABcd\\\")\"");
+    }
+}
diff --git a/metrics-core/src/test/java/com/codahale/metrics/JmxReporterTest.java b/metrics-jmx/src/test/java/com/codahale/metrics/jmx/JmxReporterTest.java
similarity index 57%
rename from metrics-core/src/test/java/com/codahale/metrics/JmxReporterTest.java
rename to metrics-jmx/src/test/java/com/codahale/metrics/jmx/JmxReporterTest.java
index 2ae96af..7c1dfa2 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/JmxReporterTest.java
+++ b/metrics-jmx/src/test/java/com/codahale/metrics/jmx/JmxReporterTest.java
@@ -1,31 +1,52 @@
-package com.codahale.metrics;
-
+package com.codahale.metrics.jmx;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-import javax.management.*;
+import javax.management.Attribute;
+import javax.management.AttributeList;
+import javax.management.InstanceNotFoundException;
+import javax.management.JMException;
+import javax.management.MBeanServer;
+import javax.management.ObjectInstance;
+import javax.management.ObjectName;
 import java.lang.management.ManagementFactory;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 
-import static org.assertj.core.api.Assertions.*;
-import static org.mockito.Mockito.*;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+@SuppressWarnings("rawtypes")
 public class JmxReporterTest {
     private final MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
     private final String name = UUID.randomUUID().toString().replaceAll("[{\\-}]", "");
     private final MetricRegistry registry = new MetricRegistry();
 
     private final JmxReporter reporter = JmxReporter.forRegistry(registry)
-                                                    .registerWith(mBeanServer)
-                                                    .inDomain(name)
-                                                    .convertDurationsTo(TimeUnit.MILLISECONDS)
-                                                    .convertRatesTo(TimeUnit.SECONDS)
-                                                    .filter(MetricFilter.ALL)
-                                                    .build();
+            .registerWith(mBeanServer)
+            .inDomain(name)
+            .convertDurationsTo(TimeUnit.MILLISECONDS)
+            .convertRatesTo(TimeUnit.SECONDS)
+            .filter(MetricFilter.ALL)
+            .build();
 
     private final Gauge gauge = mock(Gauge.class);
     private final Counter counter = mock(Counter.class);
@@ -95,47 +116,44 @@ public class JmxReporterTest {
     }
 
     @After
-    public void tearDown() throws Exception {
+    public void tearDown() {
         reporter.stop();
     }
 
     @Test
     public void registersMBeansForMetricObjectsUsingProvidedObjectNameFactory() throws Exception {
-    	ObjectName n = new ObjectName(name + ":name=dummy");
-    	try {
-    		String widgetName = "something";
-    		when(mockObjectNameFactory.createName(any(String.class), any(String.class), any(String.class))).thenReturn(n);
-    		Gauge aGauge = mock(Gauge.class);
-            when(aGauge.getValue()).thenReturn(1);
-
-    		JmxReporter reporter = JmxReporter.forRegistry(registry)
-	                .registerWith(mBeanServer)
-	                .inDomain(name)
-	                .createsObjectNamesWith(mockObjectNameFactory)
-	                .build();
-	        registry.register(widgetName, aGauge);
-	        reporter.start();
-	        verify(mockObjectNameFactory).createName(eq("gauges"), any(String.class), eq("something"));
-	        //verifyNoMoreInteractions(mockObjectNameFactory);
-    	} finally {
-    		reporter.stop();
-    		if(mBeanServer.isRegistered(n)) {
-    			mBeanServer.unregisterMBean(n);
-    		}
-    	}
+        ObjectName n = new ObjectName(name + ":name=dummy");
+        try {
+            String widgetName = "something";
+            when(mockObjectNameFactory.createName(any(String.class), any(String.class), any(String.class))).thenReturn(n);
+            JmxReporter reporter = JmxReporter.forRegistry(registry)
+                    .registerWith(mBeanServer)
+                    .inDomain(name)
+                    .createsObjectNamesWith(mockObjectNameFactory)
+                    .build();
+            registry.registerGauge(widgetName, () -> 1);
+            reporter.start();
+            verify(mockObjectNameFactory).createName(eq("gauges"), any(String.class), eq("something"));
+            //verifyNoMoreInteractions(mockObjectNameFactory);
+        } finally {
+            reporter.stop();
+            if (mBeanServer.isRegistered(n)) {
+                mBeanServer.unregisterMBean(n);
+            }
+        }
     }
-    
+
     @Test
     public void registersMBeansForGauges() throws Exception {
-        final AttributeList attributes = getAttributes("gauge", "Value");
+        final AttributeList attributes = getAttributes("gauges", "gauge", "Value", "Number");
 
         assertThat(values(attributes))
-                .contains(entry("Value", 1));
+                .contains(entry("Value", 1), entry("Number", 1));
     }
 
     @Test
     public void registersMBeansForCounters() throws Exception {
-        final AttributeList attributes = getAttributes("test.counter", "Count");
+        final AttributeList attributes = getAttributes("counters", "test.counter", "Count");
 
         assertThat(values(attributes))
                 .contains(entry("Count", 100L));
@@ -143,19 +161,19 @@ public class JmxReporterTest {
 
     @Test
     public void registersMBeansForHistograms() throws Exception {
-        final AttributeList attributes = getAttributes("test.histogram",
-                                                       "Count",
-                                                       "Max",
-                                                       "Mean",
-                                                       "Min",
-                                                       "StdDev",
-                                                       "50thPercentile",
-                                                       "75thPercentile",
-                                                       "95thPercentile",
-                                                       "98thPercentile",
-                                                       "99thPercentile",
-                                                       "999thPercentile",
-                                                       "SnapshotSize");
+        final AttributeList attributes = getAttributes("histograms", "test.histogram",
+                "Count",
+                "Max",
+                "Mean",
+                "Min",
+                "StdDev",
+                "50thPercentile",
+                "75thPercentile",
+                "95thPercentile",
+                "98thPercentile",
+                "99thPercentile",
+                "999thPercentile",
+                "SnapshotSize");
 
         assertThat(values(attributes))
                 .contains(entry("Count", 1L))
@@ -169,19 +187,18 @@ public class JmxReporterTest {
                 .contains(entry("98thPercentile", 9.0))
                 .contains(entry("99thPercentile", 10.0))
                 .contains(entry("999thPercentile", 11.0))
-                .contains(entry("SnapshotSize", 1L))
-        ;
+                .contains(entry("SnapshotSize", 1L));
     }
 
     @Test
     public void registersMBeansForMeters() throws Exception {
-        final AttributeList attributes = getAttributes("test.meter",
-                                                       "Count",
-                                                       "MeanRate",
-                                                       "OneMinuteRate",
-                                                       "FiveMinuteRate",
-                                                       "FifteenMinuteRate",
-                                                       "RateUnit");
+        final AttributeList attributes = getAttributes("meters", "test.meter",
+                "Count",
+                "MeanRate",
+                "OneMinuteRate",
+                "FiveMinuteRate",
+                "FifteenMinuteRate",
+                "RateUnit");
 
         assertThat(values(attributes))
                 .contains(entry("Count", 1L))
@@ -194,24 +211,24 @@ public class JmxReporterTest {
 
     @Test
     public void registersMBeansForTimers() throws Exception {
-        final AttributeList attributes = getAttributes("test.another.timer",
-                                                       "Count",
-                                                       "MeanRate",
-                                                       "OneMinuteRate",
-                                                       "FiveMinuteRate",
-                                                       "FifteenMinuteRate",
-                                                       "Max",
-                                                       "Mean",
-                                                       "Min",
-                                                       "StdDev",
-                                                       "50thPercentile",
-                                                       "75thPercentile",
-                                                       "95thPercentile",
-                                                       "98thPercentile",
-                                                       "99thPercentile",
-                                                       "999thPercentile",
-                                                       "RateUnit",
-                                                       "DurationUnit");
+        final AttributeList attributes = getAttributes("timers", "test.another.timer",
+                "Count",
+                "MeanRate",
+                "OneMinuteRate",
+                "FiveMinuteRate",
+                "FifteenMinuteRate",
+                "Max",
+                "Mean",
+                "Min",
+                "StdDev",
+                "50thPercentile",
+                "75thPercentile",
+                "95thPercentile",
+                "98thPercentile",
+                "99thPercentile",
+                "999thPercentile",
+                "RateUnit",
+                "DurationUnit");
 
         assertThat(values(attributes))
                 .contains(entry("Count", 1L))
@@ -238,36 +255,36 @@ public class JmxReporterTest {
         reporter.stop();
 
         try {
-            getAttributes("gauge", "Value");
+            getAttributes("gauges", "gauge", "Value", "Number");
             failBecauseExceptionWasNotThrown(InstanceNotFoundException.class);
         } catch (InstanceNotFoundException e) {
 
         }
     }
-    
+
     @Test
     public void objectNameModifyingMBeanServer() throws Exception {
-    	MBeanServer mockedMBeanServer = mock(MBeanServer.class);
-    	
-    	// overwrite the objectName
-    	when(mockedMBeanServer.registerMBean(any(Object.class), any(ObjectName.class))).thenReturn(new ObjectInstance("DOMAIN:key=value","className"));
-    	
-    	MetricRegistry testRegistry = new MetricRegistry();
-    	JmxReporter testJmxReporter = JmxReporter.forRegistry(testRegistry)
+        MBeanServer mockedMBeanServer = mock(MBeanServer.class);
+
+        // overwrite the objectName
+        when(mockedMBeanServer.registerMBean(any(Object.class), any(ObjectName.class))).thenReturn(new ObjectInstance("DOMAIN:key=value", "className"));
+
+        MetricRegistry testRegistry = new MetricRegistry();
+        JmxReporter testJmxReporter = JmxReporter.forRegistry(testRegistry)
                 .registerWith(mockedMBeanServer)
                 .inDomain(name)
                 .build();
-    	
-    	testJmxReporter.start();
-    
-    	// should trigger a registerMBean
-    	testRegistry.timer("test");
-    	
-    	// should trigger an unregisterMBean with the overwritten objectName = "DOMAIN:key=value"
-    	testJmxReporter.stop();
-    	
-    	verify(mockedMBeanServer).unregisterMBean(new ObjectName("DOMAIN:key=value"));
-    	
+
+        testJmxReporter.start();
+
+        // should trigger a registerMBean
+        testRegistry.timer("test");
+
+        // should trigger an unregisterMBean with the overwritten objectName = "DOMAIN:key=value"
+        testJmxReporter.stop();
+
+        verify(mockedMBeanServer).unregisterMBean(new ObjectName("DOMAIN:key=value"));
+
     }
 
     @Test
@@ -277,13 +294,13 @@ public class JmxReporterTest {
         metricRegistry.counter("test*");
     }
 
-    private AttributeList getAttributes(String name, String... attributeNames) throws JMException {
-    	ObjectName n = concreteObjectNameFactory.createName("only-for-logging-error", this.name, name);
+    private AttributeList getAttributes(String type, String name, String... attributeNames) throws JMException {
+        ObjectName n = concreteObjectNameFactory.createName(type, this.name, name);
         return mBeanServer.getAttributes(n, attributeNames);
     }
 
     private SortedMap<String, Object> values(AttributeList attributes) {
-        final TreeMap<String, Object> values = new TreeMap<String, Object>();
+        final TreeMap<String, Object> values = new TreeMap<>();
         for (Object o : attributes) {
             final Attribute attribute = (Attribute) o;
             values.put(attribute.getName(), attribute.getValue());
diff --git a/metrics-json/pom.xml b/metrics-json/pom.xml
index 62f2f6c..f24ca39 100644
--- a/metrics-json/pom.xml
+++ b/metrics-json/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-json</artifactId>
@@ -15,22 +15,72 @@
         A set of Jackson modules which provide serializers for most Metrics classes.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.json</javaModuleName>
+        <jackson.version>2.12.7</jackson.version>
+        <jackson-databind.version>2.12.7.1</jackson-databind.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-healthchecks</artifactId>
-            <version>${project.version}</version>
             <optional>true</optional>
         </dependency>
         <dependency>
             <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-databind</artifactId>
+            <artifactId>jackson-core</artifactId>
             <version>${jackson.version}</version>
         </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+            <version>${jackson-databind.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/metrics-json/src/main/java/com/codahale/metrics/json/HealthCheckModule.java b/metrics-json/src/main/java/com/codahale/metrics/json/HealthCheckModule.java
index 61f068c..4a8041c 100644
--- a/metrics-json/src/main/java/com/codahale/metrics/json/HealthCheckModule.java
+++ b/metrics-json/src/main/java/com/codahale/metrics/json/HealthCheckModule.java
@@ -3,19 +3,20 @@ package com.codahale.metrics.json;
 import com.codahale.metrics.health.HealthCheck;
 import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.core.Version;
-import com.fasterxml.jackson.databind.JsonSerializer;
 import com.fasterxml.jackson.databind.Module;
 import com.fasterxml.jackson.databind.SerializerProvider;
 import com.fasterxml.jackson.databind.module.SimpleSerializers;
 import com.fasterxml.jackson.databind.ser.std.StdSerializer;
 
 import java.io.IOException;
-import java.util.Arrays;
-import java.util.Iterator;
+import java.util.Collections;
 import java.util.Map;
 
 public class HealthCheckModule extends Module {
     private static class HealthCheckResultSerializer extends StdSerializer<HealthCheck.Result> {
+
+        private static final long serialVersionUID = 1L;
+
         private HealthCheckResultSerializer() {
             super(HealthCheck.Result.class);
         }
@@ -33,22 +34,23 @@ public class HealthCheckModule extends Module {
             }
 
             serializeThrowable(json, result.getError(), "error");
+            json.writeNumberField("duration", result.getDuration());
 
             Map<String, Object> details = result.getDetails();
             if (details != null && !details.isEmpty()) {
-                Iterator<Map.Entry<String, Object>> it = details.entrySet().iterator();
-                while (it.hasNext()) {
-                    Map.Entry<String, Object> e = it.next();
+                for (Map.Entry<String, Object> e : details.entrySet()) {
                     json.writeObjectField(e.getKey(), e.getValue());
                 }
             }
 
+            json.writeStringField("timestamp", result.getTimestamp());
             json.writeEndObject();
         }
 
         private void serializeThrowable(JsonGenerator json, Throwable error, String name) throws IOException {
             if (error != null) {
                 json.writeObjectFieldStart(name);
+                json.writeStringField("type", error.getClass().getTypeName());
                 json.writeStringField("message", error.getMessage());
                 json.writeArrayFieldStart("stack");
                 for (StackTraceElement element : error.getStackTrace()) {
@@ -77,8 +79,6 @@ public class HealthCheckModule extends Module {
 
     @Override
     public void setupModule(SetupContext context) {
-        context.addSerializers(new SimpleSerializers(Arrays.<JsonSerializer<?>>asList(
-                new HealthCheckResultSerializer()
-        )));
+        context.addSerializers(new SimpleSerializers(Collections.singletonList(new HealthCheckResultSerializer())));
     }
 }
diff --git a/metrics-json/src/main/java/com/codahale/metrics/json/MetricsModule.java b/metrics-json/src/main/java/com/codahale/metrics/json/MetricsModule.java
index 4dcf46c..382881e 100644
--- a/metrics-json/src/main/java/com/codahale/metrics/json/MetricsModule.java
+++ b/metrics-json/src/main/java/com/codahale/metrics/json/MetricsModule.java
@@ -1,13 +1,19 @@
 package com.codahale.metrics.json;
 
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
 import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.core.Version;
-import com.fasterxml.jackson.databind.JsonSerializer;
 import com.fasterxml.jackson.databind.Module;
 import com.fasterxml.jackson.databind.SerializerProvider;
 import com.fasterxml.jackson.databind.module.SimpleSerializers;
 import com.fasterxml.jackson.databind.ser.std.StdSerializer;
-import com.codahale.metrics.*;
 
 import java.io.IOException;
 import java.util.Arrays;
@@ -15,9 +21,13 @@ import java.util.Locale;
 import java.util.concurrent.TimeUnit;
 
 public class MetricsModule extends Module {
-    static final Version VERSION = new Version(3, 1, 3, "", "com.codahale.metrics", "metrics-json");
+    static final Version VERSION = new Version(4, 0, 0, "", "io.dropwizard.metrics", "metrics-json");
 
+    @SuppressWarnings("rawtypes")
     private static class GaugeSerializer extends StdSerializer<Gauge> {
+
+        private static final long serialVersionUID = 1L;
+
         private GaugeSerializer() {
             super(Gauge.class);
         }
@@ -39,6 +49,9 @@ public class MetricsModule extends Module {
     }
 
     private static class CounterSerializer extends StdSerializer<Counter> {
+
+        private static final long serialVersionUID = 1L;
+
         private CounterSerializer() {
             super(Counter.class);
         }
@@ -54,6 +67,9 @@ public class MetricsModule extends Module {
     }
 
     private static class HistogramSerializer extends StdSerializer<Histogram> {
+
+        private static final long serialVersionUID = 1L;
+
         private final boolean showSamples;
 
         private HistogramSerializer(boolean showSamples) {
@@ -88,6 +104,9 @@ public class MetricsModule extends Module {
     }
 
     private static class MeterSerializer extends StdSerializer<Meter> {
+
+        private static final long serialVersionUID = 1L;
+
         private final String rateUnit;
         private final double rateFactor;
 
@@ -113,6 +132,9 @@ public class MetricsModule extends Module {
     }
 
     private static class TimerSerializer extends StdSerializer<Timer> {
+
+        private static final long serialVersionUID = 1L;
+
         private final String rateUnit;
         private final double rateFactor;
         private final String durationUnit;
@@ -169,9 +191,11 @@ public class MetricsModule extends Module {
     }
 
     private static class MetricRegistrySerializer extends StdSerializer<MetricRegistry> {
-      
+
+        private static final long serialVersionUID = 1L;
+
         private final MetricFilter filter;
-        
+
         private MetricRegistrySerializer(MetricFilter filter) {
             super(MetricRegistry.class);
             this.filter = filter;
@@ -192,11 +216,11 @@ public class MetricsModule extends Module {
         }
     }
 
-    private final TimeUnit rateUnit;
-    private final TimeUnit durationUnit;
-    private final boolean showSamples;
-    private final MetricFilter filter;
-    
+    protected final TimeUnit rateUnit;
+    protected final TimeUnit durationUnit;
+    protected final boolean showSamples;
+    protected final MetricFilter filter;
+
     public MetricsModule(TimeUnit rateUnit, TimeUnit durationUnit, boolean showSamples) {
         this(rateUnit, durationUnit, showSamples, MetricFilter.ALL);
     }
@@ -220,7 +244,7 @@ public class MetricsModule extends Module {
 
     @Override
     public void setupModule(SetupContext context) {
-        context.addSerializers(new SimpleSerializers(Arrays.<JsonSerializer<?>>asList(
+        context.addSerializers(new SimpleSerializers(Arrays.asList(
                 new GaugeSerializer(),
                 new CounterSerializer(),
                 new HistogramSerializer(showSamples),
diff --git a/metrics-json/src/test/java/com/codahale/metrics/json/HealthCheckModuleTest.java b/metrics-json/src/test/java/com/codahale/metrics/json/HealthCheckModuleTest.java
index cbbd557..8518003 100644
--- a/metrics-json/src/test/java/com/codahale/metrics/json/HealthCheckModuleTest.java
+++ b/metrics-json/src/test/java/com/codahale/metrics/json/HealthCheckModuleTest.java
@@ -1,7 +1,7 @@
 package com.codahale.metrics.json;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
 import com.codahale.metrics.health.HealthCheck;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.Test;
 
 import java.math.BigDecimal;
@@ -10,86 +10,98 @@ import java.util.LinkedHashMap;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
 
 public class HealthCheckModuleTest {
     private final ObjectMapper mapper = new ObjectMapper().registerModule(new HealthCheckModule());
 
     @Test
     public void serializesAHealthyResult() throws Exception {
-        assertThat(mapper.writeValueAsString(HealthCheck.Result.healthy()))
-                .isEqualTo("{\"healthy\":true}");
+        HealthCheck.Result result = HealthCheck.Result.healthy();
+        assertThat(mapper.writeValueAsString(result))
+            .isEqualTo("{\"healthy\":true,\"duration\":0,\"timestamp\":\"" + result.getTimestamp() + "\"}");
     }
 
     @Test
     public void serializesAHealthyResultWithAMessage() throws Exception {
-        assertThat(mapper.writeValueAsString(HealthCheck.Result.healthy("yay for %s", "me")))
-                .isEqualTo("{" +
-                                   "\"healthy\":true," +
-                                   "\"message\":\"yay for me\"}");
+        HealthCheck.Result result = HealthCheck.Result.healthy("yay for %s", "me");
+        assertThat(mapper.writeValueAsString(result))
+            .isEqualTo("{" +
+                "\"healthy\":true," +
+                "\"message\":\"yay for me\"," +
+                "\"duration\":0," +
+                "\"timestamp\":\"" + result.getTimestamp() + "\"" +
+                "}");
     }
 
     @Test
     public void serializesAnUnhealthyResult() throws Exception {
-        assertThat(mapper.writeValueAsString(HealthCheck.Result.unhealthy("boo")))
-                .isEqualTo("{" +
-                                   "\"healthy\":false," +
-                                   "\"message\":\"boo\"}");
+        HealthCheck.Result result = HealthCheck.Result.unhealthy("boo");
+        assertThat(mapper.writeValueAsString(result))
+            .isEqualTo("{" +
+                "\"healthy\":false," +
+                "\"message\":\"boo\"," +
+                "\"duration\":0," +
+                "\"timestamp\":\"" + result.getTimestamp() + "\"" +
+                "}");
     }
 
     @Test
     public void serializesAnUnhealthyResultWithAnException() throws Exception {
-        final Throwable e = mock(Throwable.class);
-        when(e.getMessage()).thenReturn("oh no");
-        when(e.getStackTrace()).thenReturn(new StackTraceElement[]{
-                new StackTraceElement("Blah", "bloo", "Blah.java", 100)
+        final RuntimeException e = new RuntimeException("oh no");
+        e.setStackTrace(new StackTraceElement[]{
+            new StackTraceElement("Blah", "bloo", "Blah.java", 100)
         });
 
-        assertThat(mapper.writeValueAsString(HealthCheck.Result.unhealthy(e)))
-                .isEqualTo("{" +
-                                   "\"healthy\":false," +
-                                   "\"message\":\"oh no\"," +
-                                   "\"error\":{" +
-                                       "\"message\":\"oh no\"," +
-                                       "\"stack\":[\"Blah.bloo(Blah.java:100)\"]" +
-                                   "}" +
-                                   "}");
+        HealthCheck.Result result = HealthCheck.Result.unhealthy(e);
+        assertThat(mapper.writeValueAsString(result))
+            .isEqualTo("{" +
+                "\"healthy\":false," +
+                "\"message\":\"oh no\"," +
+                "\"error\":{" +
+                "\"type\":\"java.lang.RuntimeException\"," +
+                "\"message\":\"oh no\"," +
+                "\"stack\":[\"Blah.bloo(Blah.java:100)\"]" +
+                "}," +
+                "\"duration\":0," +
+                "\"timestamp\":\"" + result.getTimestamp() + "\"" +
+                "}");
     }
 
     @Test
     public void serializesAnUnhealthyResultWithNestedExceptions() throws Exception {
-        final Throwable a = mock(Throwable.class);
-        when(a.getMessage()).thenReturn("oh no");
-        when(a.getStackTrace()).thenReturn(new StackTraceElement[]{
+        final RuntimeException a = new RuntimeException("oh no");
+        a.setStackTrace(new StackTraceElement[]{
                 new StackTraceElement("Blah", "bloo", "Blah.java", 100)
         });
 
-        final Throwable b = mock(Throwable.class);
-        when(b.getMessage()).thenReturn("oh well");
-        when(b.getStackTrace()).thenReturn(new StackTraceElement[]{
+        final RuntimeException b = new RuntimeException("oh well", a);
+        b.setStackTrace(new StackTraceElement[]{
                 new StackTraceElement("Blah", "blee", "Blah.java", 150)
         });
-        when(b.getCause()).thenReturn(a);
 
-        assertThat(mapper.writeValueAsString(HealthCheck.Result.unhealthy(b)))
-                .isEqualTo("{" +
-                                   "\"healthy\":false," +
-                                   "\"message\":\"oh well\"," +
-                                   "\"error\":{" +
-                                       "\"message\":\"oh well\"," +
-                                       "\"stack\":[\"Blah.blee(Blah.java:150)\"]," +
-                                       "\"cause\":{" +
-                                           "\"message\":\"oh no\"," +
-                                           "\"stack\":[\"Blah.bloo(Blah.java:100)\"]" +
-                                       "}" +
-                                   "}" +
-                                   "}");
+        HealthCheck.Result result = HealthCheck.Result.unhealthy(b);
+        assertThat(mapper.writeValueAsString(result))
+            .isEqualTo("{" +
+                "\"healthy\":false," +
+                "\"message\":\"oh well\"," +
+                "\"error\":{" +
+                "\"type\":\"java.lang.RuntimeException\"," +
+                "\"message\":\"oh well\"," +
+                "\"stack\":[\"Blah.blee(Blah.java:150)\"]," +
+                "\"cause\":{" +
+                "\"type\":\"java.lang.RuntimeException\"," +
+                "\"message\":\"oh no\"," +
+                "\"stack\":[\"Blah.bloo(Blah.java:100)\"]" +
+                "}" +
+                "}," +
+                "\"duration\":0," +
+                "\"timestamp\":\"" + result.getTimestamp() + "\"" +
+                "}");
     }
 
     @Test
     public void serializeResultWithDetail() throws Exception {
-        Map<String, Object> complex = new LinkedHashMap<String, Object>();
+        Map<String, Object> complex = new LinkedHashMap<>();
         complex.put("field", "value");
 
         HealthCheck.Result result = HealthCheck.Result.builder()
@@ -108,6 +120,7 @@ public class HealthCheckModuleTest {
         assertThat(mapper.writeValueAsString(result))
             .isEqualTo("{" +
                 "\"healthy\":true," +
+                "\"duration\":0," +
                 "\"boolean\":true," +
                 "\"integer\":1," +
                 "\"long\":2," +
@@ -117,8 +130,9 @@ public class HealthCheckModuleTest {
                 "\"BigDecimal\":12345.56789," +
                 "\"String\":\"string\"," +
                 "\"complex\":{" +
-                    "\"field\":\"value\"" +
-                "}" +
-            "}");
+                "\"field\":\"value\"" +
+                "}," +
+                "\"timestamp\":\"" + result.getTimestamp() + "\"" +
+                "}");
     }
 }
diff --git a/metrics-json/src/test/java/com/codahale/metrics/json/MetricsModuleTest.java b/metrics-json/src/test/java/com/codahale/metrics/json/MetricsModuleTest.java
index bf162bd..10cebf5 100644
--- a/metrics-json/src/test/java/com/codahale/metrics/json/MetricsModuleTest.java
+++ b/metrics-json/src/test/java/com/codahale/metrics/json/MetricsModuleTest.java
@@ -1,7 +1,14 @@
 package com.codahale.metrics.json;
 
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricFilter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Snapshot;
+import com.codahale.metrics.Timer;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import com.codahale.metrics.*;
 import org.junit.Test;
 
 import java.util.concurrent.TimeUnit;
@@ -16,12 +23,7 @@ public class MetricsModuleTest {
 
     @Test
     public void serializesGauges() throws Exception {
-        final Gauge<Integer> gauge = new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return 100;
-            }
-        };
+        final Gauge<Integer> gauge = () -> 100;
 
         assertThat(mapper.writeValueAsString(gauge))
                 .isEqualTo("{\"value\":100}");
@@ -29,11 +31,8 @@ public class MetricsModuleTest {
 
     @Test
     public void serializesGaugesThatThrowExceptions() throws Exception {
-        final Gauge<Integer> gauge = new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                throw new IllegalArgumentException("poops");
-            }
+        final Gauge<Integer> gauge = () -> {
+            throw new IllegalArgumentException("poops");
         };
 
         assertThat(mapper.writeValueAsString(gauge))
@@ -65,41 +64,41 @@ public class MetricsModuleTest {
         when(snapshot.get98thPercentile()).thenReturn(9.0);
         when(snapshot.get99thPercentile()).thenReturn(10.0);
         when(snapshot.get999thPercentile()).thenReturn(11.0);
-        when(snapshot.getValues()).thenReturn(new long[]{ 1, 2, 3 });
+        when(snapshot.getValues()).thenReturn(new long[]{1, 2, 3});
 
         when(histogram.getSnapshot()).thenReturn(snapshot);
 
         assertThat(mapper.writeValueAsString(histogram))
                 .isEqualTo("{" +
-                                   "\"count\":1," +
-                                   "\"max\":2," +
-                                   "\"mean\":3.0," +
-                                   "\"min\":4," +
-                                   "\"p50\":6.0," +
-                                   "\"p75\":7.0," +
-                                   "\"p95\":8.0," +
-                                   "\"p98\":9.0," +
-                                   "\"p99\":10.0," +
-                                   "\"p999\":11.0," +
-                                   "\"stddev\":5.0}");
+                        "\"count\":1," +
+                        "\"max\":2," +
+                        "\"mean\":3.0," +
+                        "\"min\":4," +
+                        "\"p50\":6.0," +
+                        "\"p75\":7.0," +
+                        "\"p95\":8.0," +
+                        "\"p98\":9.0," +
+                        "\"p99\":10.0," +
+                        "\"p999\":11.0," +
+                        "\"stddev\":5.0}");
 
         final ObjectMapper fullMapper = new ObjectMapper().registerModule(
                 new MetricsModule(TimeUnit.SECONDS, TimeUnit.MILLISECONDS, true, MetricFilter.ALL));
 
         assertThat(fullMapper.writeValueAsString(histogram))
                 .isEqualTo("{" +
-                                   "\"count\":1," +
-                                   "\"max\":2," +
-                                   "\"mean\":3.0," +
-                                   "\"min\":4," +
-                                   "\"p50\":6.0," +
-                                   "\"p75\":7.0," +
-                                   "\"p95\":8.0," +
-                                   "\"p98\":9.0," +
-                                   "\"p99\":10.0," +
-                                   "\"p999\":11.0," +
-                                   "\"values\":[1,2,3]," +
-                                   "\"stddev\":5.0}");
+                        "\"count\":1," +
+                        "\"max\":2," +
+                        "\"mean\":3.0," +
+                        "\"min\":4," +
+                        "\"p50\":6.0," +
+                        "\"p75\":7.0," +
+                        "\"p95\":8.0," +
+                        "\"p98\":9.0," +
+                        "\"p99\":10.0," +
+                        "\"p999\":11.0," +
+                        "\"values\":[1,2,3]," +
+                        "\"stddev\":5.0}");
     }
 
     @Test
@@ -113,12 +112,12 @@ public class MetricsModuleTest {
 
         assertThat(mapper.writeValueAsString(meter))
                 .isEqualTo("{" +
-                                   "\"count\":1," +
-                                   "\"m15_rate\":3.0," +
-                                   "\"m1_rate\":5.0," +
-                                   "\"m5_rate\":4.0," +
-                                   "\"mean_rate\":2.0," +
-                                   "\"units\":\"events/second\"}");
+                        "\"count\":1," +
+                        "\"m15_rate\":3.0," +
+                        "\"m1_rate\":5.0," +
+                        "\"m5_rate\":4.0," +
+                        "\"mean_rate\":2.0," +
+                        "\"units\":\"events/second\"}");
     }
 
     @Test
@@ -152,47 +151,47 @@ public class MetricsModuleTest {
 
         assertThat(mapper.writeValueAsString(timer))
                 .isEqualTo("{" +
-                                   "\"count\":1," +
-                                   "\"max\":100.0," +
-                                   "\"mean\":200.0," +
-                                   "\"min\":300.0," +
-                                   "\"p50\":500.0," +
-                                   "\"p75\":600.0," +
-                                   "\"p95\":700.0," +
-                                   "\"p98\":800.0," +
-                                   "\"p99\":900.0," +
-                                   "\"p999\":1000.0," +
-                                   "\"stddev\":400.0," +
-                                   "\"m15_rate\":5.0," +
-                                   "\"m1_rate\":3.0," +
-                                   "\"m5_rate\":4.0," +
-                                   "\"mean_rate\":2.0," +
-                                   "\"duration_units\":\"milliseconds\"," +
-                                   "\"rate_units\":\"calls/second\"}");
+                        "\"count\":1," +
+                        "\"max\":100.0," +
+                        "\"mean\":200.0," +
+                        "\"min\":300.0," +
+                        "\"p50\":500.0," +
+                        "\"p75\":600.0," +
+                        "\"p95\":700.0," +
+                        "\"p98\":800.0," +
+                        "\"p99\":900.0," +
+                        "\"p999\":1000.0," +
+                        "\"stddev\":400.0," +
+                        "\"m15_rate\":5.0," +
+                        "\"m1_rate\":3.0," +
+                        "\"m5_rate\":4.0," +
+                        "\"mean_rate\":2.0," +
+                        "\"duration_units\":\"milliseconds\"," +
+                        "\"rate_units\":\"calls/second\"}");
 
         final ObjectMapper fullMapper = new ObjectMapper().registerModule(
                 new MetricsModule(TimeUnit.SECONDS, TimeUnit.MILLISECONDS, true, MetricFilter.ALL));
 
         assertThat(fullMapper.writeValueAsString(timer))
                 .isEqualTo("{" +
-                                   "\"count\":1," +
-                                   "\"max\":100.0," +
-                                   "\"mean\":200.0," +
-                                   "\"min\":300.0," +
-                                   "\"p50\":500.0," +
-                                   "\"p75\":600.0," +
-                                   "\"p95\":700.0," +
-                                   "\"p98\":800.0," +
-                                   "\"p99\":900.0," +
-                                   "\"p999\":1000.0," +
-                                   "\"values\":[1.0,2.0,3.0]," +
-                                   "\"stddev\":400.0," +
-                                   "\"m15_rate\":5.0," +
-                                   "\"m1_rate\":3.0," +
-                                   "\"m5_rate\":4.0," +
-                                   "\"mean_rate\":2.0," +
-                                   "\"duration_units\":\"milliseconds\"," +
-                                   "\"rate_units\":\"calls/second\"}");
+                        "\"count\":1," +
+                        "\"max\":100.0," +
+                        "\"mean\":200.0," +
+                        "\"min\":300.0," +
+                        "\"p50\":500.0," +
+                        "\"p75\":600.0," +
+                        "\"p95\":700.0," +
+                        "\"p98\":800.0," +
+                        "\"p99\":900.0," +
+                        "\"p999\":1000.0," +
+                        "\"values\":[1.0,2.0,3.0]," +
+                        "\"stddev\":400.0," +
+                        "\"m15_rate\":5.0," +
+                        "\"m1_rate\":3.0," +
+                        "\"m5_rate\":4.0," +
+                        "\"mean_rate\":2.0," +
+                        "\"duration_units\":\"milliseconds\"," +
+                        "\"rate_units\":\"calls/second\"}");
     }
 
     @Test
@@ -201,11 +200,11 @@ public class MetricsModuleTest {
 
         assertThat(mapper.writeValueAsString(registry))
                 .isEqualTo("{" +
-                                   "\"version\":\"3.1.3\"," +
-                                   "\"gauges\":{}," +
-                                   "\"counters\":{}," +
-                                   "\"histograms\":{}," +
-                                   "\"meters\":{}," +
-                                   "\"timers\":{}}");
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{}," +
+                        "\"counters\":{}," +
+                        "\"histograms\":{}," +
+                        "\"meters\":{}," +
+                        "\"timers\":{}}");
     }
 }
diff --git a/metrics-jvm/pom.xml b/metrics-jvm/pom.xml
index 7c1ff70..2aeaf23 100644
--- a/metrics-jvm/pom.xml
+++ b/metrics-jvm/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-jvm</artifactId>
@@ -16,11 +16,60 @@
         using Metrics.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.jvm</javaModuleName>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 </project>
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/BufferPoolMetricSet.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/BufferPoolMetricSet.java
index db82391..b7f64b3 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/BufferPoolMetricSet.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/BufferPoolMetricSet.java
@@ -1,6 +1,5 @@
 package com.codahale.metrics.jvm;
 
-import com.codahale.metrics.JmxAttributeGauge;
 import com.codahale.metrics.Metric;
 import com.codahale.metrics.MetricSet;
 import org.slf4j.Logger;
@@ -22,9 +21,9 @@ import static com.codahale.metrics.MetricRegistry.name;
  */
 public class BufferPoolMetricSet implements MetricSet {
     private static final Logger LOGGER = LoggerFactory.getLogger(BufferPoolMetricSet.class);
-    private static final String[] ATTRIBUTES = { "Count", "MemoryUsed", "TotalCapacity" };
-    private static final String[] NAMES = { "count", "used", "capacity" };
-    private static final String[] POOLS = { "direct", "mapped" };
+    private static final String[] ATTRIBUTES = {"Count", "MemoryUsed", "TotalCapacity"};
+    private static final String[] NAMES = {"count", "used", "capacity"};
+    private static final String[] POOLS = {"direct", "mapped"};
 
     private final MBeanServer mBeanServer;
 
@@ -34,7 +33,7 @@ public class BufferPoolMetricSet implements MetricSet {
 
     @Override
     public Map<String, Metric> getMetrics() {
-        final Map<String, Metric> gauges = new HashMap<String, Metric>();
+        final Map<String, Metric> gauges = new HashMap<>();
         for (String pool : POOLS) {
             for (int i = 0; i < ATTRIBUTES.length; i++) {
                 final String attribute = ATTRIBUTES[i];
@@ -42,8 +41,7 @@ public class BufferPoolMetricSet implements MetricSet {
                 try {
                     final ObjectName on = new ObjectName("java.nio:type=BufferPool,name=" + pool);
                     mBeanServer.getMBeanInfo(on);
-                    gauges.put(name(pool, name),
-                               new JmxAttributeGauge(mBeanServer, on, attribute));
+                    gauges.put(name(pool, name), new JmxAttributeGauge(mBeanServer, on, attribute));
                 } catch (JMException ignored) {
                     LOGGER.debug("Unable to load buffer pool MBeans, possibly running on Java 6");
                 }
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/CachedThreadStatesGaugeSet.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/CachedThreadStatesGaugeSet.java
index b6bbad0..f2fd8c2 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/CachedThreadStatesGaugeSet.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/CachedThreadStatesGaugeSet.java
@@ -29,7 +29,7 @@ public class CachedThreadStatesGaugeSet extends ThreadStatesGaugeSet {
         super(threadMXBean, deadlockDetector);
         threadInfo = new CachedGauge<ThreadInfo[]>(interval, unit) {
             @Override
-           protected ThreadInfo[] loadValue() {
+            protected ThreadInfo[] loadValue() {
                 return CachedThreadStatesGaugeSet.super.getThreadInfo();
             }
         };
@@ -38,8 +38,9 @@ public class CachedThreadStatesGaugeSet extends ThreadStatesGaugeSet {
     /**
      * Creates a new set of gauges using the default MXBeans.
      * Caches the information for the given interval and time unit.
-     * @param interval         cache interval
-     * @param unit             cache interval time unit
+     *
+     * @param interval cache interval
+     * @param unit     cache interval time unit
      */
     public CachedThreadStatesGaugeSet(long interval, TimeUnit unit) {
         this(ManagementFactory.getThreadMXBean(), new ThreadDeadlockDetector(), interval, unit);
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ClassLoadingGaugeSet.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ClassLoadingGaugeSet.java
index 0e5c6b3..89def18 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ClassLoadingGaugeSet.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ClassLoadingGaugeSet.java
@@ -26,21 +26,9 @@ public class ClassLoadingGaugeSet implements MetricSet {
 
     @Override
     public Map<String, Metric> getMetrics() {
-        final Map<String, Metric> gauges = new HashMap<String, Metric>();
-
-        gauges.put("loaded", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getTotalLoadedClassCount();
-            }
-        });
-
-        gauges.put("unloaded", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getUnloadedClassCount();
-            }
-        });
+        final Map<String, Metric> gauges = new HashMap<>();
+        gauges.put("loaded", (Gauge<Long>) mxBean::getTotalLoadedClassCount);
+        gauges.put("unloaded", (Gauge<Long>) mxBean::getUnloadedClassCount);
 
         return gauges;
     }
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/CpuTimeClock.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/CpuTimeClock.java
new file mode 100644
index 0000000..1385308
--- /dev/null
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/CpuTimeClock.java
@@ -0,0 +1,19 @@
+package com.codahale.metrics.jvm;
+
+import com.codahale.metrics.Clock;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.ThreadMXBean;
+
+/**
+ * A clock implementation which returns the current thread's CPU time.
+ */
+public class CpuTimeClock extends Clock {
+
+    private static final ThreadMXBean THREAD_MX_BEAN = ManagementFactory.getThreadMXBean();
+
+    @Override
+    public long getTick() {
+        return THREAD_MX_BEAN.getCurrentThreadCpuTime();
+    }
+}
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/FileDescriptorRatioGauge.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/FileDescriptorRatioGauge.java
index d6f2bb3..4b51479 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/FileDescriptorRatioGauge.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/FileDescriptorRatioGauge.java
@@ -4,15 +4,24 @@ import com.codahale.metrics.RatioGauge;
 
 import java.lang.management.ManagementFactory;
 import java.lang.management.OperatingSystemMXBean;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
 
 /**
  * A gauge for the ratio of used to total file descriptors.
  */
 public class FileDescriptorRatioGauge extends RatioGauge {
+    private static boolean unixOperatingSystemMXBeanExists = false;
+
     private final OperatingSystemMXBean os;
 
+    static {
+        try {
+            Class.forName("com.sun.management.UnixOperatingSystemMXBean");
+            unixOperatingSystemMXBeanExists = true;
+        } catch (ClassNotFoundException e) {
+            // do nothing
+        }
+    }
+
     /**
      * Creates a new gauge using the platform OS bean.
      */
@@ -23,7 +32,7 @@ public class FileDescriptorRatioGauge extends RatioGauge {
     /**
      * Creates a new gauge using the given OS bean.
      *
-     * @param os    an {@link OperatingSystemMXBean}
+     * @param os an {@link OperatingSystemMXBean}
      */
     public FileDescriptorRatioGauge(OperatingSystemMXBean os) {
         this.os = os;
@@ -31,21 +40,11 @@ public class FileDescriptorRatioGauge extends RatioGauge {
 
     @Override
     protected Ratio getRatio() {
-        try {
-            return Ratio.of(invoke("getOpenFileDescriptorCount"),
-                            invoke("getMaxFileDescriptorCount"));
-        } catch (NoSuchMethodException e) {
-            return Ratio.of(Double.NaN, Double.NaN);
-        } catch (IllegalAccessException e) {
-            return Ratio.of(Double.NaN, Double.NaN);
-        } catch (InvocationTargetException e) {
+        if (unixOperatingSystemMXBeanExists && os instanceof com.sun.management.UnixOperatingSystemMXBean) {
+            final com.sun.management.UnixOperatingSystemMXBean unixOs = (com.sun.management.UnixOperatingSystemMXBean) os;
+            return Ratio.of(unixOs.getOpenFileDescriptorCount(), unixOs.getMaxFileDescriptorCount());
+        } else {
             return Ratio.of(Double.NaN, Double.NaN);
         }
     }
-
-    private long invoke(String name) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
-        final Method method = os.getClass().getDeclaredMethod(name);
-        method.setAccessible(true);
-        return (Long) method.invoke(os);
-    }
 }
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/GarbageCollectorMetricSet.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/GarbageCollectorMetricSet.java
index 90741fc..705f6e0 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/GarbageCollectorMetricSet.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/GarbageCollectorMetricSet.java
@@ -6,7 +6,12 @@ import com.codahale.metrics.MetricSet;
 
 import java.lang.management.GarbageCollectorMXBean;
 import java.lang.management.ManagementFactory;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.regex.Pattern;
 
 import static com.codahale.metrics.MetricRegistry.name;
@@ -29,30 +34,19 @@ public class GarbageCollectorMetricSet implements MetricSet {
     /**
      * Creates a new set of gauges for the given collection of garbage collectors.
      *
-     * @param garbageCollectors    the garbage collectors
+     * @param garbageCollectors the garbage collectors
      */
     public GarbageCollectorMetricSet(Collection<GarbageCollectorMXBean> garbageCollectors) {
-        this.garbageCollectors = new ArrayList<GarbageCollectorMXBean>(garbageCollectors);
+        this.garbageCollectors = new ArrayList<>(garbageCollectors);
     }
 
     @Override
     public Map<String, Metric> getMetrics() {
-        final Map<String, Metric> gauges = new HashMap<String, Metric>();
+        final Map<String, Metric> gauges = new HashMap<>();
         for (final GarbageCollectorMXBean gc : garbageCollectors) {
             final String name = WHITESPACE.matcher(gc.getName()).replaceAll("-");
-            gauges.put(name(name, "count"), new Gauge<Long>() {
-                @Override
-                public Long getValue() {
-                    return gc.getCollectionCount();
-                }
-            });
-
-            gauges.put(name(name, "time"), new Gauge<Long>() {
-                @Override
-                public Long getValue() {
-                    return gc.getCollectionTime();
-                }
-            });
+            gauges.put(name(name, "count"), (Gauge<Long>) gc::getCollectionCount);
+            gauges.put(name(name, "time"), (Gauge<Long>) gc::getCollectionTime);
         }
         return Collections.unmodifiableMap(gauges);
     }
diff --git a/metrics-core/src/main/java/com/codahale/metrics/JmxAttributeGauge.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/JmxAttributeGauge.java
similarity index 84%
rename from metrics-core/src/main/java/com/codahale/metrics/JmxAttributeGauge.java
rename to metrics-jvm/src/main/java/com/codahale/metrics/jvm/JmxAttributeGauge.java
index b49fa16..46ef9fe 100644
--- a/metrics-core/src/main/java/com/codahale/metrics/JmxAttributeGauge.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/JmxAttributeGauge.java
@@ -1,4 +1,6 @@
-package com.codahale.metrics;
+package com.codahale.metrics.jvm;
+
+import com.codahale.metrics.Gauge;
 
 import java.io.IOException;
 import javax.management.JMException;
@@ -28,9 +30,9 @@ public class JmxAttributeGauge implements Gauge<Object> {
     /**
      * Creates a new JmxAttributeGauge.
      *
-     * @param mBeanServerConn  the {@link MBeanServerConnection}
-     * @param objectName       the name of the object
-     * @param attributeName    the name of the object's attribute
+     * @param mBeanServerConn the {@link MBeanServerConnection}
+     * @param objectName      the name of the object
+     * @param attributeName   the name of the object's attribute
      */
     public JmxAttributeGauge(MBeanServerConnection mBeanServerConn, ObjectName objectName, String attributeName) {
         this.mBeanServerConn = mBeanServerConn;
@@ -42,9 +44,7 @@ public class JmxAttributeGauge implements Gauge<Object> {
     public Object getValue() {
         try {
             return mBeanServerConn.getAttribute(getObjectName(), attributeName);
-        } catch (IOException e) {
-            return null;
-        } catch (JMException e) {
+        } catch (IOException | JMException e) {
             return null;
         }
     }
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/JvmAttributeGaugeSet.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/JvmAttributeGaugeSet.java
new file mode 100644
index 0000000..308ecc7
--- /dev/null
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/JvmAttributeGaugeSet.java
@@ -0,0 +1,51 @@
+package com.codahale.metrics.jvm;
+
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricSet;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.RuntimeMXBean;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * A set of gauges for the JVM name, vendor, and uptime.
+ */
+public class JvmAttributeGaugeSet implements MetricSet {
+    private final RuntimeMXBean runtime;
+
+    /**
+     * Creates a new set of gauges.
+     */
+    public JvmAttributeGaugeSet() {
+        this(ManagementFactory.getRuntimeMXBean());
+    }
+
+    /**
+     * Creates a new set of gauges with the given {@link RuntimeMXBean}.
+     *
+     * @param runtime JVM management interface with access to system properties
+     */
+    public JvmAttributeGaugeSet(RuntimeMXBean runtime) {
+        this.runtime = runtime;
+    }
+
+    @Override
+    public Map<String, Metric> getMetrics() {
+        final Map<String, Metric> gauges = new HashMap<>();
+
+        gauges.put("name", (Gauge<String>) runtime::getName);
+        gauges.put("vendor", (Gauge<String>) () -> String.format(Locale.US,
+                "%s %s %s (%s)",
+                runtime.getVmVendor(),
+                runtime.getVmName(),
+                runtime.getVmVersion(),
+                runtime.getSpecVersion()));
+        gauges.put("uptime", (Gauge<Long>) runtime::getUptime);
+
+        return Collections.unmodifiableMap(gauges);
+    }
+}
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/MemoryUsageGaugeSet.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/MemoryUsageGaugeSet.java
index 949e948..933b720 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/MemoryUsageGaugeSet.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/MemoryUsageGaugeSet.java
@@ -9,7 +9,12 @@ import java.lang.management.ManagementFactory;
 import java.lang.management.MemoryMXBean;
 import java.lang.management.MemoryPoolMXBean;
 import java.lang.management.MemoryUsage;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.regex.Pattern;
 
 import static com.codahale.metrics.MetricRegistry.name;
@@ -25,81 +30,32 @@ public class MemoryUsageGaugeSet implements MetricSet {
     private final List<MemoryPoolMXBean> memoryPools;
 
     public MemoryUsageGaugeSet() {
-        this(ManagementFactory.getMemoryMXBean(),
-             ManagementFactory.getMemoryPoolMXBeans());
+        this(ManagementFactory.getMemoryMXBean(), ManagementFactory.getMemoryPoolMXBeans());
     }
 
     public MemoryUsageGaugeSet(MemoryMXBean mxBean,
                                Collection<MemoryPoolMXBean> memoryPools) {
         this.mxBean = mxBean;
-        this.memoryPools = new ArrayList<MemoryPoolMXBean>(memoryPools);
+        this.memoryPools = new ArrayList<>(memoryPools);
     }
 
     @Override
     public Map<String, Metric> getMetrics() {
-        final Map<String, Metric> gauges = new HashMap<String, Metric>();
-
-        gauges.put("total.init", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getInit() +
-                        mxBean.getNonHeapMemoryUsage().getInit();
-            }
-        });
-
-        gauges.put("total.used", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getUsed() +
-                        mxBean.getNonHeapMemoryUsage().getUsed();
-            }
-        });
-
-        gauges.put("total.max", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getMax() +
-                        mxBean.getNonHeapMemoryUsage().getMax();
-            }
-        });
-
-        gauges.put("total.committed", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getCommitted() +
-                        mxBean.getNonHeapMemoryUsage().getCommitted();
-            }
-        });
-
-
-        gauges.put("heap.init", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getInit();
-            }
-        });
-
-        gauges.put("heap.used", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getUsed();
-            }
-        });
-
-        gauges.put("heap.max", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getMax();
-            }
-        });
-
-        gauges.put("heap.committed", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getHeapMemoryUsage().getCommitted();
-            }
-        });
-
+        final Map<String, Metric> gauges = new HashMap<>();
+
+        gauges.put("total.init", (Gauge<Long>) () -> mxBean.getHeapMemoryUsage().getInit() +
+                mxBean.getNonHeapMemoryUsage().getInit());
+        gauges.put("total.used", (Gauge<Long>) () -> mxBean.getHeapMemoryUsage().getUsed() +
+                mxBean.getNonHeapMemoryUsage().getUsed());
+        gauges.put("total.max", (Gauge<Long>) () -> mxBean.getNonHeapMemoryUsage().getMax() == -1 ?
+                -1 : mxBean.getHeapMemoryUsage().getMax() + mxBean.getNonHeapMemoryUsage().getMax());
+        gauges.put("total.committed", (Gauge<Long>) () -> mxBean.getHeapMemoryUsage().getCommitted() +
+                mxBean.getNonHeapMemoryUsage().getCommitted());
+
+        gauges.put("heap.init", (Gauge<Long>) () -> mxBean.getHeapMemoryUsage().getInit());
+        gauges.put("heap.used", (Gauge<Long>) () -> mxBean.getHeapMemoryUsage().getUsed());
+        gauges.put("heap.max", (Gauge<Long>) () -> mxBean.getHeapMemoryUsage().getMax());
+        gauges.put("heap.committed", (Gauge<Long>) () -> mxBean.getHeapMemoryUsage().getCommitted());
         gauges.put("heap.usage", new RatioGauge() {
             @Override
             protected Ratio getRatio() {
@@ -108,92 +64,41 @@ public class MemoryUsageGaugeSet implements MetricSet {
             }
         });
 
-        gauges.put("non-heap.init", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getNonHeapMemoryUsage().getInit();
-            }
-        });
-
-        gauges.put("non-heap.used", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getNonHeapMemoryUsage().getUsed();
-            }
-        });
-
-        gauges.put("non-heap.max", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getNonHeapMemoryUsage().getMax();
-            }
-        });
-
-        gauges.put("non-heap.committed", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return mxBean.getNonHeapMemoryUsage().getCommitted();
-            }
-        });
-
+        gauges.put("non-heap.init", (Gauge<Long>) () -> mxBean.getNonHeapMemoryUsage().getInit());
+        gauges.put("non-heap.used", (Gauge<Long>) () -> mxBean.getNonHeapMemoryUsage().getUsed());
+        gauges.put("non-heap.max", (Gauge<Long>) () -> mxBean.getNonHeapMemoryUsage().getMax());
+        gauges.put("non-heap.committed", (Gauge<Long>) () -> mxBean.getNonHeapMemoryUsage().getCommitted());
         gauges.put("non-heap.usage", new RatioGauge() {
             @Override
             protected Ratio getRatio() {
                 final MemoryUsage usage = mxBean.getNonHeapMemoryUsage();
-                return Ratio.of(usage.getUsed(), usage.getMax());
+                return Ratio.of(usage.getUsed(), usage.getMax() == -1 ? usage.getCommitted() : usage.getMax());
             }
         });
 
         for (final MemoryPoolMXBean pool : memoryPools) {
             final String poolName = name("pools", WHITESPACE.matcher(pool.getName()).replaceAll("-"));
 
-            gauges.put(name(poolName, "usage"),
-                    new RatioGauge() {
-                           @Override
-                           protected Ratio getRatio() {
-                               MemoryUsage usage = pool.getUsage();
-                               return Ratio.of(usage.getUsed(),
-                                       usage.getMax() == -1 ? usage.getCommitted() : usage.getMax());
-                           }
-                    });
-
-            gauges.put(name(poolName, "max"),new Gauge<Long>() {
+            gauges.put(name(poolName, "usage"), new RatioGauge() {
                 @Override
-                public Long getValue() {
-                    return pool.getUsage().getMax();
+                protected Ratio getRatio() {
+                    MemoryUsage usage = pool.getUsage();
+                    return Ratio.of(usage.getUsed(),
+                            usage.getMax() == -1 ? usage.getCommitted() : usage.getMax());
                 }
             });
 
-            gauges.put(name(poolName, "used"),new Gauge<Long>() {
-                @Override
-                public Long getValue() {
-                    return pool.getUsage().getUsed();
-                }
-            });
-
-            gauges.put(name(poolName, "committed"),new Gauge<Long>() {
-                @Override
-                public Long getValue() {
-                    return pool.getUsage().getCommitted();
-                }
-            });
+            gauges.put(name(poolName, "max"), (Gauge<Long>) () -> pool.getUsage().getMax());
+            gauges.put(name(poolName, "used"), (Gauge<Long>) () -> pool.getUsage().getUsed());
+            gauges.put(name(poolName, "committed"), (Gauge<Long>) () -> pool.getUsage().getCommitted());
 
             // Only register GC usage metrics if the memory pool supports usage statistics.
             if (pool.getCollectionUsage() != null) {
-            	gauges.put(name(poolName, "used-after-gc"),new Gauge<Long>() {
-                    @Override
-                    public Long getValue() {
-                        return pool.getCollectionUsage().getUsed();
-                    }
-                });
+                gauges.put(name(poolName, "used-after-gc"), (Gauge<Long>) () ->
+                        pool.getCollectionUsage().getUsed());
             }
 
-            gauges.put(name(poolName, "init"),new Gauge<Long>() {
-                @Override
-                public Long getValue() {
-                    return pool.getUsage().getInit();
-                }
-            });
+            gauges.put(name(poolName, "init"), (Gauge<Long>) () -> pool.getUsage().getInit());
         }
 
         return Collections.unmodifiableMap(gauges);
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDeadlockDetector.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDeadlockDetector.java
index e6dd697..4a9ed22 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDeadlockDetector.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDeadlockDetector.java
@@ -25,7 +25,7 @@ public class ThreadDeadlockDetector {
     /**
      * Creates a new detector using the given {@link ThreadMXBean}.
      *
-     * @param threads    a {@link ThreadMXBean}
+     * @param threads a {@link ThreadMXBean}
      */
     public ThreadDeadlockDetector(ThreadMXBean threads) {
         this.threads = threads;
@@ -40,21 +40,21 @@ public class ThreadDeadlockDetector {
     public Set<String> getDeadlockedThreads() {
         final long[] ids = threads.findDeadlockedThreads();
         if (ids != null) {
-            final Set<String> deadlocks = new HashSet<String>();
+            final Set<String> deadlocks = new HashSet<>();
             for (ThreadInfo info : threads.getThreadInfo(ids, MAX_STACK_TRACE_DEPTH)) {
                 final StringBuilder stackTrace = new StringBuilder();
                 for (StackTraceElement element : info.getStackTrace()) {
                     stackTrace.append("\t at ")
-                              .append(element.toString())
-                              .append(String.format("%n"));
+                            .append(element.toString())
+                            .append(String.format("%n"));
                 }
 
                 deadlocks.add(
                         String.format("%s locked on %s (owned by %s):%n%s",
-                                      info.getThreadName(),
-                                      info.getLockName(),
-                                      info.getLockOwnerName(),
-                                      stackTrace.toString()
+                                info.getThreadName(),
+                                info.getLockName(),
+                                info.getLockOwnerName(),
+                                stackTrace.toString()
                         )
                 );
             }
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDump.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDump.java
index a001961..6ca1d7d 100755
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDump.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadDump.java
@@ -7,13 +7,14 @@ import java.lang.management.LockInfo;
 import java.lang.management.MonitorInfo;
 import java.lang.management.ThreadInfo;
 import java.lang.management.ThreadMXBean;
-import java.nio.charset.Charset;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 
 /**
  * A convenience class for getting a thread dump.
  */
 public class ThreadDump {
-    private static final Charset UTF_8 = Charset.forName("UTF-8");
 
     private final ThreadMXBean threadMXBean;
 
@@ -22,32 +23,49 @@ public class ThreadDump {
     }
 
     /**
-     * Dumps all of the threads' current information to an output stream.
+     * Dumps all of the threads' current information, including synchronization, to an output stream.
      *
      * @param out an output stream
      */
     public void dump(OutputStream out) {
-        final ThreadInfo[] threads = this.threadMXBean.dumpAllThreads(true, true);
+        dump(true, true, out);
+    }
+
+    /**
+     * Dumps all of the threads' current information, optionally including synchronization, to an output stream.
+     *
+     * Having control over including synchronization info allows using this method (and its wrappers, i.e.
+     * ThreadDumpServlet) in environments where getting object monitor and/or ownable synchronizer usage is not
+     * supported. It can also speed things up.
+     *
+     * See {@link ThreadMXBean#dumpAllThreads(boolean, boolean)}
+     *
+     * @param lockedMonitors dump all locked monitors if true
+     * @param lockedSynchronizers dump all locked ownable synchronizers if true
+     * @param out an output stream
+     */
+    public void dump(boolean lockedMonitors, boolean lockedSynchronizers, OutputStream out) {
+        final ThreadInfo[] threads = this.threadMXBean.dumpAllThreads(lockedMonitors, lockedSynchronizers);
         final PrintWriter writer = new PrintWriter(new OutputStreamWriter(out, UTF_8));
 
         for (int ti = threads.length - 1; ti >= 0; ti--) {
             final ThreadInfo t = threads[ti];
             writer.printf("\"%s\" id=%d state=%s",
-                          t.getThreadName(),
-                          t.getThreadId(),
-                          t.getThreadState());
+                    t.getThreadName(),
+                    t.getThreadId(),
+                    t.getThreadState());
             final LockInfo lock = t.getLockInfo();
             if (lock != null && t.getThreadState() != Thread.State.BLOCKED) {
                 writer.printf("%n    - waiting on <0x%08x> (a %s)",
-                              lock.getIdentityHashCode(),
-                              lock.getClassName());
+                        lock.getIdentityHashCode(),
+                        lock.getClassName());
                 writer.printf("%n    - locked <0x%08x> (a %s)",
-                              lock.getIdentityHashCode(),
-                              lock.getClassName());
+                        lock.getIdentityHashCode(),
+                        lock.getClassName());
             } else if (lock != null && t.getThreadState() == Thread.State.BLOCKED) {
                 writer.printf("%n    - waiting to lock <0x%08x> (a %s)",
-                              lock.getIdentityHashCode(),
-                              lock.getClassName());
+                        lock.getIdentityHashCode(),
+                        lock.getClassName());
             }
 
             if (t.isSuspended()) {
diff --git a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadStatesGaugeSet.java b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadStatesGaugeSet.java
index a79d6bc..9b796a7 100644
--- a/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadStatesGaugeSet.java
+++ b/metrics-jvm/src/main/java/com/codahale/metrics/jvm/ThreadStatesGaugeSet.java
@@ -46,45 +46,19 @@ public class ThreadStatesGaugeSet implements MetricSet {
 
     @Override
     public Map<String, Metric> getMetrics() {
-        final Map<String, Metric> gauges = new HashMap<String, Metric>();
+        final Map<String, Metric> gauges = new HashMap<>();
 
         for (final Thread.State state : Thread.State.values()) {
             gauges.put(name(state.toString().toLowerCase(), "count"),
-                       new Gauge<Object>() {
-                           @Override
-                           public Object getValue() {
-                               return getThreadCount(state);
-                           }
-                       });
+                    (Gauge<Object>) () -> getThreadCount(state));
         }
 
-        gauges.put("count", new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return threads.getThreadCount();
-            }
-        });
-
-        gauges.put("daemon.count", new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return threads.getDaemonThreadCount();
-            }
-        });
-
-        gauges.put("deadlock.count", new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                return deadlockDetector.getDeadlockedThreads().size();
-            }
-        });
-
-        gauges.put("deadlocks", new Gauge<Set<String>>() {
-            @Override
-            public Set<String> getValue() {
-                return deadlockDetector.getDeadlockedThreads();
-            }
-        });
+        gauges.put("count", (Gauge<Integer>) threads::getThreadCount);
+        gauges.put("daemon.count", (Gauge<Integer>) threads::getDaemonThreadCount);
+        gauges.put("peak.count", (Gauge<Integer>) threads::getPeakThreadCount);
+        gauges.put("total_started.count", (Gauge<Long>) threads::getTotalStartedThreadCount);
+        gauges.put("deadlock.count", (Gauge<Integer>) () -> deadlockDetector.getDeadlockedThreads().size());
+        gauges.put("deadlocks", (Gauge<Set<String>>) deadlockDetector::getDeadlockedThreads);
 
         return Collections.unmodifiableMap(gauges);
     }
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/BufferPoolMetricSetTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/BufferPoolMetricSetTest.java
index 2f8aba3..f3d0cd9 100644
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/BufferPoolMetricSetTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/BufferPoolMetricSetTest.java
@@ -12,6 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+@SuppressWarnings("rawtypes")
 public class BufferPoolMetricSetTest {
     private final MBeanServer mBeanServer = mock(MBeanServer.class);
     private final BufferPoolMetricSet buffers = new BufferPoolMetricSet(mBeanServer);
@@ -27,14 +28,14 @@ public class BufferPoolMetricSetTest {
     }
 
     @Test
-    public void includesGaugesForDirectAndMappedPools() throws Exception {
+    public void includesGaugesForDirectAndMappedPools() {
         assertThat(buffers.getMetrics().keySet())
                 .containsOnly("direct.count",
-                              "mapped.used",
-                              "mapped.capacity",
-                              "direct.capacity",
-                              "mapped.count",
-                              "direct.used");
+                        "mapped.used",
+                        "mapped.capacity",
+                        "direct.capacity",
+                        "mapped.count",
+                        "direct.used");
     }
 
     @Test
@@ -43,8 +44,8 @@ public class BufferPoolMetricSetTest {
 
         assertThat(buffers.getMetrics().keySet())
                 .containsOnly("direct.count",
-                              "direct.capacity",
-                              "direct.used");
+                        "direct.capacity",
+                        "direct.used");
     }
 
     @Test
@@ -76,7 +77,7 @@ public class BufferPoolMetricSetTest {
         assertThat(gauge.getValue())
                 .isEqualTo(100);
     }
-    
+
     @Test
     public void includesAGaugeForMappedCount() throws Exception {
         final Gauge gauge = (Gauge) buffers.getMetrics().get("mapped.count");
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ClassLoadingGaugeSetTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ClassLoadingGaugeSetTest.java
index b46a6dc..1597672 100644
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ClassLoadingGaugeSetTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ClassLoadingGaugeSetTest.java
@@ -10,25 +10,26 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+@SuppressWarnings("rawtypes")
 public class ClassLoadingGaugeSetTest {
 
     private final ClassLoadingMXBean cl = mock(ClassLoadingMXBean.class);
     private final ClassLoadingGaugeSet gauges = new ClassLoadingGaugeSet(cl);
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         when(cl.getTotalLoadedClassCount()).thenReturn(2L);
         when(cl.getUnloadedClassCount()).thenReturn(1L);
     }
 
     @Test
-    public void loadedGauge() throws Exception {
+    public void loadedGauge() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("loaded");
         assertThat(gauge.getValue()).isEqualTo(2L);
     }
 
     @Test
-    public void unLoadedGauge() throws Exception {
+    public void unLoadedGauge() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("unloaded");
         assertThat(gauge.getValue()).isEqualTo(1L);
     }
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/CpuTimeClockTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/CpuTimeClockTest.java
new file mode 100644
index 0000000..f3a96d4
--- /dev/null
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/CpuTimeClockTest.java
@@ -0,0 +1,24 @@
+package com.codahale.metrics.jvm;
+
+import org.junit.Test;
+
+import java.lang.management.ManagementFactory;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.offset;
+
+public class CpuTimeClockTest {
+
+    @Test
+    public void cpuTimeClock() {
+        final CpuTimeClock clock = new CpuTimeClock();
+
+        assertThat((double) clock.getTime())
+                .isEqualTo(System.currentTimeMillis(),
+                        offset(250D));
+
+        assertThat((double) clock.getTick())
+                .isEqualTo(ManagementFactory.getThreadMXBean().getCurrentThreadCpuTime(),
+                        offset(1000000.0));
+    }
+}
\ No newline at end of file
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/FileDescriptorRatioGaugeSunManagementNotExistsTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/FileDescriptorRatioGaugeSunManagementNotExistsTest.java
new file mode 100644
index 0000000..48a514a
--- /dev/null
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/FileDescriptorRatioGaugeSunManagementNotExistsTest.java
@@ -0,0 +1,117 @@
+package com.codahale.metrics.jvm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.security.AccessController;
+import java.security.CodeSource;
+import java.security.PermissionCollection;
+import java.security.Permissions;
+import java.security.PrivilegedAction;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.BlockJUnit4ClassRunner;
+import org.junit.runners.model.InitializationError;
+
+@RunWith(FileDescriptorRatioGaugeSunManagementNotExistsTest.SunManagementNotExistsTestRunner.class)
+public class FileDescriptorRatioGaugeSunManagementNotExistsTest {
+
+    @Test
+    public void validateFileDescriptorRatioWhenSunManagementNotExists() {
+        assertThat(new FileDescriptorRatioGauge().getValue()).isNaN();
+    }
+
+    public static class SunManagementNotExistsTestRunner extends BlockJUnit4ClassRunner {
+
+        public SunManagementNotExistsTestRunner(Class<?> clazz) throws InitializationError {
+            super(getFromSunManagementNotExistsClassLoader(clazz));
+        }
+
+        private static Class<?> getFromSunManagementNotExistsClassLoader(Class<?> clazz) throws InitializationError {
+            try {
+                return Class.forName(clazz.getName(), true,
+                        new SunManagementNotExistsClassLoader(SunManagementNotExistsTestRunner.class.getClassLoader()));
+            } catch (ClassNotFoundException e) {
+                throw new InitializationError(e);
+            }
+        }
+    }
+
+    public static class SunManagementNotExistsClassLoader extends URLClassLoader {
+        private static final URL[] CLASSPATH_ENTRY_URLS;
+        private static final PermissionCollection NO_PERMS = new Permissions();
+
+        static {
+            String[] classpathEntries = AccessController.doPrivileged(new PrivilegedAction<String>() {
+                @Override
+                public String run() {
+                    return System.getProperty("java.class.path");
+                }
+            }).split(File.pathSeparator);
+            CLASSPATH_ENTRY_URLS = getClasspathEntryUrls(classpathEntries);
+        }
+
+        private static URL[] getClasspathEntryUrls(String... classpathEntries) {
+            Set<URL> classpathEntryUrls = new LinkedHashSet<>(classpathEntries.length, 1);
+            for (String classpathEntry : classpathEntries) {
+                URL classpathEntryUrl = getClasspathEntryUrl(classpathEntry);
+                if (classpathEntryUrl != null) {
+                    classpathEntryUrls.add(classpathEntryUrl);
+                }
+            }
+            return classpathEntryUrls.toArray(new URL[classpathEntryUrls.size()]);
+        }
+
+        private static URL getClasspathEntryUrl(String classpathEntry) {
+            try {
+                if (classpathEntry.endsWith(".jar")) {
+                    return new URL("file:jar:" + classpathEntry);
+                }
+                if (!classpathEntry.endsWith("/")) {
+                    classpathEntry = classpathEntry + "/";
+                }
+                return new URL("file:" + classpathEntry);
+            } catch (MalformedURLException mue) {
+                // do nothing
+            }
+            return null;
+        }
+
+        public SunManagementNotExistsClassLoader(ClassLoader parent) {
+            super(CLASSPATH_ENTRY_URLS, parent);
+        }
+
+        @Override
+        protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
+            if (getClass().getName().equals(name)) {
+                return getClass();
+            }
+            if (name.startsWith("com.sun.management.")) {
+                throw new ClassNotFoundException(name);
+            }
+            if (name.startsWith("com.codahale.metrics.jvm.")) {
+                return loadMetricsClasses(name);
+            }
+            return super.loadClass(name, resolve);
+        }
+
+        private Class<?> loadMetricsClasses(String name) throws ClassNotFoundException {
+            Class<?> ret = findLoadedClass(name);
+            if (ret != null) {
+                return ret;
+            }
+            return findClass(name);
+        }
+
+        @Override
+        protected PermissionCollection getPermissions(CodeSource codesource) {
+            return NO_PERMS;
+        }
+    }
+}
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/FileDescriptorRatioGaugeTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/FileDescriptorRatioGaugeTest.java
index 750e281..0599a7a 100644
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/FileDescriptorRatioGaugeTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/FileDescriptorRatioGaugeTest.java
@@ -1,5 +1,7 @@
 package com.codahale.metrics.jvm;
 
+import com.sun.management.UnixOperatingSystemMXBean;
+import org.junit.Before;
 import org.junit.Test;
 
 import javax.management.ObjectName;
@@ -9,70 +11,38 @@ import java.lang.management.OperatingSystemMXBean;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.Assume.assumeTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 @SuppressWarnings("UnusedDeclaration")
 public class FileDescriptorRatioGaugeTest {
-    private final OperatingSystemMXBean os = new OperatingSystemMXBean() {
-        @Override
-        public String getName() {
-            return null;
-        }
+    private final UnixOperatingSystemMXBean os = mock(UnixOperatingSystemMXBean.class);
 
-        @Override
-        public String getArch() {
-            return null;
-        }
-
-        @Override
-        public String getVersion() {
-            return null;
-        }
-
-        @Override
-        public int getAvailableProcessors() {
-            return 0;
-        }
-
-        @Override
-        public double getSystemLoadAverage() {
-            return 0;
-        }
-
-        // these duplicate methods from UnixOperatingSystem
-
-        private long getOpenFileDescriptorCount() {
-            return 10;
-        }
-
-        private long getMaxFileDescriptorCount() {
-            return 100;
-        }
-
-        // overridden on Java 1.7; random crap on Java 1.6
-        public ObjectName getObjectName() {
-            return null;
-        }
-    };
     private final FileDescriptorRatioGauge gauge = new FileDescriptorRatioGauge(os);
 
+    @Before
+    public void setUp() throws Exception {
+        when(os.getOpenFileDescriptorCount()).thenReturn(10L);
+        when(os.getMaxFileDescriptorCount()).thenReturn(100L);
+    }
+
     @Test
-    public void calculatesTheRatioOfUsedToTotalFileDescriptors() throws Exception {
+    public void calculatesTheRatioOfUsedToTotalFileDescriptors() {
         assertThat(gauge.getValue())
                 .isEqualTo(0.1);
     }
 
     @Test
-    public void validateFileDescriptorRatioPresenceOnNixPlatforms() throws Exception {
+    public void validateFileDescriptorRatioPresenceOnNixPlatforms() {
         OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
         assumeTrue(osBean instanceof com.sun.management.UnixOperatingSystemMXBean);
-        
+
         assertThat(new FileDescriptorRatioGauge().getValue())
                 .isGreaterThanOrEqualTo(0.0)
                 .isLessThanOrEqualTo(1.0);
     }
 
     @Test
-    public void returnsNaNWhenTheInformationIsUnavailable() throws Exception {
+    public void returnsNaNWhenTheInformationIsUnavailable() {
         assertThat(new FileDescriptorRatioGauge(mock(OperatingSystemMXBean.class)).getValue())
                 .isNaN();
     }
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/GarbageCollectorMetricSetTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/GarbageCollectorMetricSetTest.java
index ff0e3d3..9c4734c 100644
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/GarbageCollectorMetricSetTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/GarbageCollectorMetricSetTest.java
@@ -5,45 +5,46 @@ import org.junit.Before;
 import org.junit.Test;
 
 import java.lang.management.GarbageCollectorMXBean;
-import java.util.Arrays;
+import java.util.Collections;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+@SuppressWarnings("unchecked")
 public class GarbageCollectorMetricSetTest {
     private final GarbageCollectorMXBean gc = mock(GarbageCollectorMXBean.class);
-    private final GarbageCollectorMetricSet metrics = new GarbageCollectorMetricSet(Arrays.asList(gc));
+    private final GarbageCollectorMetricSet metrics = new GarbageCollectorMetricSet(Collections.singletonList(gc));
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         when(gc.getName()).thenReturn("PS OldGen");
         when(gc.getCollectionCount()).thenReturn(1L);
         when(gc.getCollectionTime()).thenReturn(2L);
     }
 
     @Test
-    public void hasGaugesForGcCountsAndElapsedTimes() throws Exception {
+    public void hasGaugesForGcCountsAndElapsedTimes() {
         assertThat(metrics.getMetrics().keySet())
                 .containsOnly("PS-OldGen.time", "PS-OldGen.count");
     }
 
     @Test
-    public void hasAGaugeForGcCounts() throws Exception {
-        final Gauge gauge = (Gauge) metrics.getMetrics().get("PS-OldGen.count");
+    public void hasAGaugeForGcCounts() {
+        final Gauge<Long> gauge = (Gauge<Long>) metrics.getMetrics().get("PS-OldGen.count");
         assertThat(gauge.getValue())
                 .isEqualTo(1L);
     }
 
     @Test
-    public void hasAGaugeForGcTimes() throws Exception {
-        final Gauge gauge = (Gauge) metrics.getMetrics().get("PS-OldGen.time");
+    public void hasAGaugeForGcTimes() {
+        final Gauge<Long> gauge = (Gauge<Long>) metrics.getMetrics().get("PS-OldGen.time");
         assertThat(gauge.getValue())
                 .isEqualTo(2L);
     }
 
     @Test
-    public void autoDiscoversGCs() throws Exception {
+    public void autoDiscoversGCs() {
         assertThat(new GarbageCollectorMetricSet().getMetrics().keySet())
                 .isNotEmpty();
     }
diff --git a/metrics-core/src/test/java/com/codahale/metrics/JmxAttributeGaugeTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/JmxAttributeGaugeTest.java
similarity index 98%
rename from metrics-core/src/test/java/com/codahale/metrics/JmxAttributeGaugeTest.java
rename to metrics-jvm/src/test/java/com/codahale/metrics/jvm/JmxAttributeGaugeTest.java
index 02f77f6..c709a2c 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/JmxAttributeGaugeTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/JmxAttributeGaugeTest.java
@@ -1,4 +1,4 @@
-package com.codahale.metrics;
+package com.codahale.metrics.jvm;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -19,7 +19,7 @@ public class JmxAttributeGaugeTest {
 
     private static MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
 
-    private static List<ObjectName> registeredMBeans = new ArrayList<ObjectName>();
+    private static List<ObjectName> registeredMBeans = new ArrayList<>();
 
     public interface JmxTestMBean {
         Long getValue();
diff --git a/metrics-core/src/test/java/com/codahale/metrics/JvmAttributeGaugeSetTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/JvmAttributeGaugeSetTest.java
similarity index 63%
rename from metrics-core/src/test/java/com/codahale/metrics/JvmAttributeGaugeSetTest.java
rename to metrics-jvm/src/test/java/com/codahale/metrics/jvm/JvmAttributeGaugeSetTest.java
index 1251dfa..43f49bd 100644
--- a/metrics-core/src/test/java/com/codahale/metrics/JvmAttributeGaugeSetTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/JvmAttributeGaugeSetTest.java
@@ -1,5 +1,6 @@
-package com.codahale.metrics;
+package com.codahale.metrics.jvm;
 
+import com.codahale.metrics.Gauge;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -9,12 +10,13 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+@SuppressWarnings("unchecked")
 public class JvmAttributeGaugeSetTest {
     private final RuntimeMXBean runtime = mock(RuntimeMXBean.class);
     private final JvmAttributeGaugeSet gauges = new JvmAttributeGaugeSet(runtime);
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         when(runtime.getName()).thenReturn("9928@example.com");
 
         when(runtime.getVmVendor()).thenReturn("Oracle Corporation");
@@ -25,40 +27,39 @@ public class JvmAttributeGaugeSetTest {
     }
 
     @Test
-    public void hasASetOfGauges() throws Exception {
+    public void hasASetOfGauges() {
         assertThat(gauges.getMetrics().keySet())
                 .containsOnly("vendor", "name", "uptime");
     }
 
     @Test
-    public void hasAGaugeForTheJVMName() throws Exception {
-        final Gauge gauge = (Gauge) gauges.getMetrics().get("name");
+    public void hasAGaugeForTheJVMName() {
+        final Gauge<String> gauge = (Gauge<String>) gauges.getMetrics().get("name");
 
         assertThat(gauge.getValue())
                 .isEqualTo("9928@example.com");
     }
 
     @Test
-    public void hasAGaugeForTheJVMVendor() throws Exception {
-        final Gauge gauge = (Gauge) gauges.getMetrics().get("vendor");
+    public void hasAGaugeForTheJVMVendor() {
+        final Gauge<String> gauge = (Gauge<String>) gauges.getMetrics().get("vendor");
 
         assertThat(gauge.getValue())
                 .isEqualTo("Oracle Corporation Java HotSpot(TM) 64-Bit Server VM 23.7-b01 (1.7)");
     }
 
     @Test
-    public void hasAGaugeForTheJVMUptime() throws Exception {
-        final Gauge gauge = (Gauge) gauges.getMetrics().get("uptime");
+    public void hasAGaugeForTheJVMUptime() {
+        final Gauge<Long> gauge = (Gauge<Long>) gauges.getMetrics().get("uptime");
 
         assertThat(gauge.getValue())
                 .isEqualTo(100L);
     }
 
     @Test
-    public void autoDiscoversTheRuntimeBean() throws Exception {
-        final Gauge gauge = (Gauge) new JvmAttributeGaugeSet().getMetrics().get("uptime");
+    public void autoDiscoversTheRuntimeBean() {
+        final Gauge<Long> gauge = (Gauge<Long>) new JvmAttributeGaugeSet().getMetrics().get("uptime");
 
-        assertThat((Long) gauge.getValue())
-                .isPositive();
+        assertThat(gauge.getValue()).isPositive();
     }
 }
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/MemoryUsageGaugeSetTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/MemoryUsageGaugeSetTest.java
index 4929627..4e92369 100644
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/MemoryUsageGaugeSetTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/MemoryUsageGaugeSetTest.java
@@ -13,6 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+@SuppressWarnings("rawtypes")
 public class MemoryUsageGaugeSetTest {
     private final MemoryUsage heap = mock(MemoryUsage.class);
     private final MemoryUsage nonHeap = mock(MemoryUsage.class);
@@ -24,11 +25,11 @@ public class MemoryUsageGaugeSetTest {
     private final MemoryPoolMXBean weirdMemoryPool = mock(MemoryPoolMXBean.class);
 
     private final MemoryUsageGaugeSet gauges = new MemoryUsageGaugeSet(mxBean,
-                                                                       Arrays.asList(memoryPool,
-                                                                                     weirdMemoryPool));
+            Arrays.asList(memoryPool,
+                    weirdMemoryPool));
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         when(heap.getCommitted()).thenReturn(10L);
         when(heap.getInit()).thenReturn(20L);
         when(heap.getUsed()).thenReturn(30L);
@@ -66,7 +67,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasASetOfGauges() throws Exception {
+    public void hasASetOfGauges() {
         assertThat(gauges.getMetrics().keySet())
                 .containsOnly(
                         "heap.init",
@@ -98,7 +99,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForTotalCommitted() throws Exception {
+    public void hasAGaugeForTotalCommitted() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("total.committed");
 
         assertThat(gauge.getValue())
@@ -106,7 +107,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForTotalInit() throws Exception {
+    public void hasAGaugeForTotalInit() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("total.init");
 
         assertThat(gauge.getValue())
@@ -114,7 +115,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForTotalUsed() throws Exception {
+    public void hasAGaugeForTotalUsed() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("total.used");
 
         assertThat(gauge.getValue())
@@ -122,7 +123,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForTotalMax() throws Exception {
+    public void hasAGaugeForTotalMax() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("total.max");
 
         assertThat(gauge.getValue())
@@ -130,7 +131,17 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForHeapCommitted() throws Exception {
+    public void hasAGaugeForTotalMaxWhenNonHeapMaxUndefined() {
+        when(nonHeap.getMax()).thenReturn(-1L);
+
+        final Gauge gauge = (Gauge) gauges.getMetrics().get("total.max");
+
+        assertThat(gauge.getValue())
+                .isEqualTo(-1L);
+    }
+
+    @Test
+    public void hasAGaugeForHeapCommitted() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("heap.committed");
 
         assertThat(gauge.getValue())
@@ -138,7 +149,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForHeapInit() throws Exception {
+    public void hasAGaugeForHeapInit() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("heap.init");
 
         assertThat(gauge.getValue())
@@ -146,7 +157,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForHeapUsed() throws Exception {
+    public void hasAGaugeForHeapUsed() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("heap.used");
 
         assertThat(gauge.getValue())
@@ -154,7 +165,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForHeapMax() throws Exception {
+    public void hasAGaugeForHeapMax() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("heap.max");
 
         assertThat(gauge.getValue())
@@ -162,7 +173,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForHeapUsage() throws Exception {
+    public void hasAGaugeForHeapUsage() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("heap.usage");
 
         assertThat(gauge.getValue())
@@ -170,7 +181,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForNonHeapCommitted() throws Exception {
+    public void hasAGaugeForNonHeapCommitted() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("non-heap.committed");
 
         assertThat(gauge.getValue())
@@ -178,7 +189,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForNonHeapInit() throws Exception {
+    public void hasAGaugeForNonHeapInit() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("non-heap.init");
 
         assertThat(gauge.getValue())
@@ -186,7 +197,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForNonHeapUsed() throws Exception {
+    public void hasAGaugeForNonHeapUsed() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("non-heap.used");
 
         assertThat(gauge.getValue())
@@ -194,7 +205,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForNonHeapMax() throws Exception {
+    public void hasAGaugeForNonHeapMax() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("non-heap.max");
 
         assertThat(gauge.getValue())
@@ -202,7 +213,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForNonHeapUsage() throws Exception {
+    public void hasAGaugeForNonHeapUsage() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("non-heap.usage");
 
         assertThat(gauge.getValue())
@@ -210,7 +221,16 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForMemoryPoolUsage() throws Exception {
+    public void hasAGaugeForNonHeapUsageWhenNonHeapMaxUndefined() {
+        when(nonHeap.getMax()).thenReturn(-1L);
+        final Gauge gauge = (Gauge) gauges.getMetrics().get("non-heap.usage");
+
+        assertThat(gauge.getValue())
+                .isEqualTo(3.0);
+    }
+
+    @Test
+    public void hasAGaugeForMemoryPoolUsage() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("pools.Big-Pool.usage");
 
         assertThat(gauge.getValue())
@@ -218,7 +238,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForWeirdMemoryPoolInit() throws Exception {
+    public void hasAGaugeForWeirdMemoryPoolInit() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("pools.Weird-Pool.init");
 
         assertThat(gauge.getValue())
@@ -226,7 +246,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForWeirdMemoryPoolCommitted() throws Exception {
+    public void hasAGaugeForWeirdMemoryPoolCommitted() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("pools.Weird-Pool.committed");
 
         assertThat(gauge.getValue())
@@ -234,7 +254,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForWeirdMemoryPoolUsed() throws Exception {
+    public void hasAGaugeForWeirdMemoryPoolUsed() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("pools.Weird-Pool.used");
 
         assertThat(gauge.getValue())
@@ -242,7 +262,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForWeirdMemoryPoolUsage() throws Exception {
+    public void hasAGaugeForWeirdMemoryPoolUsage() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("pools.Weird-Pool.usage");
 
         assertThat(gauge.getValue())
@@ -250,7 +270,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForWeirdMemoryPoolMax() throws Exception {
+    public void hasAGaugeForWeirdMemoryPoolMax() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("pools.Weird-Pool.max");
 
         assertThat(gauge.getValue())
@@ -258,7 +278,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void hasAGaugeForWeirdCollectionPoolUsed() throws Exception {
+    public void hasAGaugeForWeirdCollectionPoolUsed() {
         final Gauge gauge = (Gauge) gauges.getMetrics().get("pools.Weird-Pool.used-after-gc");
 
         assertThat(gauge.getValue())
@@ -266,7 +286,7 @@ public class MemoryUsageGaugeSetTest {
     }
 
     @Test
-    public void autoDetectsMemoryUsageBeanAndMemoryPools() throws Exception {
+    public void autoDetectsMemoryUsageBeanAndMemoryPools() {
         assertThat(new MemoryUsageGaugeSet().getMetrics().keySet())
                 .isNotEmpty();
     }
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDeadlockDetectorTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDeadlockDetectorTest.java
index 2070f37..fd128a0 100644
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDeadlockDetectorTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDeadlockDetectorTest.java
@@ -7,8 +7,8 @@ import java.lang.management.ThreadMXBean;
 import java.util.Locale;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -17,22 +17,22 @@ public class ThreadDeadlockDetectorTest {
     private final ThreadDeadlockDetector detector = new ThreadDeadlockDetector(threads);
 
     @Test
-    public void returnsAnEmptySetIfNoThreadsAreDeadlocked() throws Exception {
+    public void returnsAnEmptySetIfNoThreadsAreDeadlocked() {
         when(threads.findDeadlockedThreads()).thenReturn(null);
 
         assertThat(detector.getDeadlockedThreads())
-                .isEmpty();
+            .isEmpty();
     }
 
     @Test
-    public void returnsASetOfThreadsIfAnyAreDeadlocked() throws Exception {
+    public void returnsASetOfThreadsIfAnyAreDeadlocked() {
         final ThreadInfo thread1 = mock(ThreadInfo.class);
         when(thread1.getThreadName()).thenReturn("thread1");
         when(thread1.getLockName()).thenReturn("lock2");
         when(thread1.getLockOwnerName()).thenReturn("thread2");
         when(thread1.getStackTrace()).thenReturn(new StackTraceElement[]{
-                new StackTraceElement("Blah", "bloo", "Blah.java", 150),
-                new StackTraceElement("Blah", "blee", "Blah.java", 100)
+            new StackTraceElement("Blah", "bloo", "Blah.java", 150),
+            new StackTraceElement("Blah", "blee", "Blah.java", 100)
         });
 
         final ThreadInfo thread2 = mock(ThreadInfo.class);
@@ -40,29 +40,29 @@ public class ThreadDeadlockDetectorTest {
         when(thread2.getLockName()).thenReturn("lock1");
         when(thread2.getLockOwnerName()).thenReturn("thread1");
         when(thread2.getStackTrace()).thenReturn(new StackTraceElement[]{
-                new StackTraceElement("Blah", "blee", "Blah.java", 100),
-                new StackTraceElement("Blah", "bloo", "Blah.java", 150)
+            new StackTraceElement("Blah", "blee", "Blah.java", 100),
+            new StackTraceElement("Blah", "bloo", "Blah.java", 150)
         });
 
-        final long[] ids = { 1, 2 };
+        final long[] ids = {1, 2};
         when(threads.findDeadlockedThreads()).thenReturn(ids);
         when(threads.getThreadInfo(eq(ids), anyInt()))
-                .thenReturn(new ThreadInfo[]{ thread1, thread2 });
+            .thenReturn(new ThreadInfo[]{thread1, thread2});
 
         assertThat(detector.getDeadlockedThreads())
-                .containsOnly(String.format(Locale.US,
-                                            "thread1 locked on lock2 (owned by thread2):%n" +
-                                                    "\t at Blah.bloo(Blah.java:150)%n" +
-                                                    "\t at Blah.blee(Blah.java:100)%n"),
-                              String.format(Locale.US,
-                                            "thread2 locked on lock1 (owned by thread1):%n" +
-                                                    "\t at Blah.blee(Blah.java:100)%n" +
-                                                    "\t at Blah.bloo(Blah.java:150)%n"));
+            .containsOnly(String.format(Locale.US,
+                "thread1 locked on lock2 (owned by thread2):%n" +
+                    "\t at Blah.bloo(Blah.java:150)%n" +
+                    "\t at Blah.blee(Blah.java:100)%n"),
+                String.format(Locale.US,
+                    "thread2 locked on lock1 (owned by thread1):%n" +
+                        "\t at Blah.blee(Blah.java:100)%n" +
+                        "\t at Blah.bloo(Blah.java:150)%n"));
     }
 
     @Test
-    public void autoDiscoversTheThreadMXBean() throws Exception {
+    public void autoDiscoversTheThreadMXBean() {
         assertThat(new ThreadDeadlockDetector().getDeadlockedThreads())
-                .isNotNull();
+            .isNotNull();
     }
 }
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDumpTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDumpTest.java
index a62b6da..d73f98d 100755
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDumpTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadDumpTest.java
@@ -22,30 +22,30 @@ public class ThreadDumpTest {
     private final ThreadInfo runnable = mock(ThreadInfo.class);
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         final StackTraceElement rLine1 = new StackTraceElement("Blah", "blee", "Blah.java", 100);
 
         when(runnable.getThreadName()).thenReturn("runnable");
         when(runnable.getThreadId()).thenReturn(100L);
         when(runnable.getThreadState()).thenReturn(Thread.State.RUNNABLE);
-        when(runnable.getStackTrace()).thenReturn(new StackTraceElement[]{ rLine1 });
-        when(runnable.getLockedMonitors()).thenReturn(new MonitorInfo[]{ });
-        when(runnable.getLockedSynchronizers()).thenReturn(new LockInfo[]{ });
+        when(runnable.getStackTrace()).thenReturn(new StackTraceElement[]{rLine1});
+        when(runnable.getLockedMonitors()).thenReturn(new MonitorInfo[]{});
+        when(runnable.getLockedSynchronizers()).thenReturn(new LockInfo[]{});
 
         when(threadMXBean.dumpAllThreads(true, true)).thenReturn(new ThreadInfo[]{
-                runnable
+            runnable
         });
     }
 
     @Test
-    public void dumpsAllThreads() throws Exception {
+    public void dumpsAllThreads() {
         final ByteArrayOutputStream output = new ByteArrayOutputStream();
         threadDump.dump(output);
 
         assertThat(output.toString())
-                .isEqualTo(String.format("\"runnable\" id=100 state=RUNNABLE%n" +
-                                                 "    at Blah.blee(Blah.java:100)%n" +
-                                                 "%n" +
-                                                 "%n"));
+            .isEqualTo(String.format("\"runnable\" id=100 state=RUNNABLE%n" +
+                "    at Blah.blee(Blah.java:100)%n" +
+                "%n" +
+                "%n"));
     }
 }
diff --git a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadStatesGaugeSetTest.java b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadStatesGaugeSetTest.java
index 0ddd116..df235a7 100644
--- a/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadStatesGaugeSetTest.java
+++ b/metrics-jvm/src/test/java/com/codahale/metrics/jvm/ThreadStatesGaugeSetTest.java
@@ -17,7 +17,7 @@ public class ThreadStatesGaugeSetTest {
     private final ThreadMXBean threads = mock(ThreadMXBean.class);
     private final ThreadDeadlockDetector detector = mock(ThreadDeadlockDetector.class);
     private final ThreadStatesGaugeSet gauges = new ThreadStatesGaugeSet(threads, detector);
-    private final long[] ids = new long[]{ 1, 2, 3 };
+    private final long[] ids = new long[]{1, 2, 3};
 
     private final ThreadInfo newThread = mock(ThreadInfo.class);
     private final ThreadInfo runnableThread = mock(ThreadInfo.class);
@@ -26,10 +26,10 @@ public class ThreadStatesGaugeSetTest {
     private final ThreadInfo timedWaitingThread = mock(ThreadInfo.class);
     private final ThreadInfo terminatedThread = mock(ThreadInfo.class);
 
-    private final Set<String> deadlocks = new HashSet<String>();
+    private final Set<String> deadlocks = new HashSet<>();
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         deadlocks.add("yay");
 
         when(newThread.getThreadState()).thenReturn(Thread.State.NEW);
@@ -41,82 +41,98 @@ public class ThreadStatesGaugeSetTest {
 
         when(threads.getAllThreadIds()).thenReturn(ids);
         when(threads.getThreadInfo(ids, 0)).thenReturn(new ThreadInfo[]{
-                newThread, runnableThread, blockedThread,
-                waitingThread, timedWaitingThread, terminatedThread
+            newThread, runnableThread, blockedThread,
+            waitingThread, timedWaitingThread, terminatedThread
         });
 
         when(threads.getThreadCount()).thenReturn(12);
-        when(threads.getDaemonThreadCount()).thenReturn(13);
+        when(threads.getDaemonThreadCount()).thenReturn(10);
+        when(threads.getPeakThreadCount()).thenReturn(30);
+        when(threads.getTotalStartedThreadCount()).thenReturn(42L);
 
         when(detector.getDeadlockedThreads()).thenReturn(deadlocks);
     }
 
     @Test
-    public void hasASetOfGauges() throws Exception {
+    public void hasASetOfGauges() {
         assertThat(gauges.getMetrics().keySet())
-                .containsOnly("terminated.count",
-                              "new.count",
-                              "count",
-                              "timed_waiting.count",
-                              "deadlocks",
-                              "blocked.count",
-                              "waiting.count",
-                              "daemon.count",
-                              "runnable.count",
-                              "deadlock.count");
+            .containsOnly("terminated.count",
+                "new.count",
+                "count",
+                "timed_waiting.count",
+                "deadlocks",
+                "blocked.count",
+                "waiting.count",
+                "daemon.count",
+                "runnable.count",
+                "deadlock.count",
+                "total_started.count",
+                "peak.count");
     }
 
     @Test
-    public void hasAGaugeForEachThreadState() throws Exception {
-        assertThat(((Gauge) gauges.getMetrics().get("new.count")).getValue())
-                .isEqualTo(1);
+    public void hasAGaugeForEachThreadState() {
+        assertThat(((Gauge<?>) gauges.getMetrics().get("new.count")).getValue())
+            .isEqualTo(1);
 
-        assertThat(((Gauge) gauges.getMetrics().get("runnable.count")).getValue())
-                .isEqualTo(1);
+        assertThat(((Gauge<?>) gauges.getMetrics().get("runnable.count")).getValue())
+            .isEqualTo(1);
 
-        assertThat(((Gauge) gauges.getMetrics().get("blocked.count")).getValue())
-                .isEqualTo(1);
+        assertThat(((Gauge<?>) gauges.getMetrics().get("blocked.count")).getValue())
+            .isEqualTo(1);
 
-        assertThat(((Gauge) gauges.getMetrics().get("waiting.count")).getValue())
-                .isEqualTo(1);
+        assertThat(((Gauge<?>) gauges.getMetrics().get("waiting.count")).getValue())
+            .isEqualTo(1);
 
-        assertThat(((Gauge) gauges.getMetrics().get("timed_waiting.count")).getValue())
-                .isEqualTo(1);
+        assertThat(((Gauge<?>) gauges.getMetrics().get("timed_waiting.count")).getValue())
+            .isEqualTo(1);
 
-        assertThat(((Gauge) gauges.getMetrics().get("terminated.count")).getValue())
-                .isEqualTo(1);
+        assertThat(((Gauge<?>) gauges.getMetrics().get("terminated.count")).getValue())
+            .isEqualTo(1);
     }
 
     @Test
-    public void hasAGaugeForTheNumberOfThreads() throws Exception {
-        assertThat(((Gauge) gauges.getMetrics().get("count")).getValue())
-                .isEqualTo(12);
+    public void hasAGaugeForTheNumberOfThreads() {
+        assertThat(((Gauge<?>) gauges.getMetrics().get("count")).getValue())
+            .isEqualTo(12);
     }
 
     @Test
-    public void hasAGaugeForTheNumberOfDaemonThreads() throws Exception {
-        assertThat(((Gauge) gauges.getMetrics().get("daemon.count")).getValue())
-                .isEqualTo(13);
+    public void hasAGaugeForTheNumberOfDaemonThreads() {
+        assertThat(((Gauge<?>) gauges.getMetrics().get("daemon.count")).getValue())
+            .isEqualTo(10);
     }
 
     @Test
-    public void hasAGaugeForAnyDeadlocks() throws Exception {
-        assertThat(((Gauge) gauges.getMetrics().get("deadlocks")).getValue())
-                .isEqualTo(deadlocks);
+    public void hasAGaugeForAnyDeadlocks() {
+        assertThat(((Gauge<?>) gauges.getMetrics().get("deadlocks")).getValue())
+            .isEqualTo(deadlocks);
     }
 
     @Test
-    public void hasAGaugeForAnyDeadlockCount() throws Exception {
-        assertThat(((Gauge) gauges.getMetrics().get("deadlock.count")).getValue())
-                .isEqualTo(1);
+    public void hasAGaugeForAnyDeadlockCount() {
+        assertThat(((Gauge<?>) gauges.getMetrics().get("deadlock.count")).getValue())
+            .isEqualTo(1);
     }
 
     @Test
-    public void autoDiscoversTheMXBeans() throws Exception {
+    public void hasAGaugeForPeakThreadCount() {
+        assertThat(((Gauge<?>) gauges.getMetrics().get("peak.count")).getValue())
+            .isEqualTo(30);
+    }
+
+    @Test
+    public void hasAGaugeForTotalStartedThreadsCount() {
+        assertThat(((Gauge<?>) gauges.getMetrics().get("total_started.count")).getValue())
+            .isEqualTo(42L);
+    }
+
+    @Test
+    public void autoDiscoversTheMXBeans() {
         final ThreadStatesGaugeSet set = new ThreadStatesGaugeSet();
-        assertThat(((Gauge) set.getMetrics().get("count")).getValue())
-                .isNotNull();
-        assertThat(((Gauge) set.getMetrics().get("deadlocks")).getValue())
-                .isNotNull();
+        assertThat(((Gauge<?>) set.getMetrics().get("count")).getValue())
+            .isNotNull();
+        assertThat(((Gauge<?>) set.getMetrics().get("deadlocks")).getValue())
+            .isNotNull();
     }
 }
diff --git a/metrics-log4j/pom.xml b/metrics-log4j/pom.xml
deleted file mode 100644
index a008fea..0000000
--- a/metrics-log4j/pom.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<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>io.dropwizard.metrics</groupId>
-        <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
-    </parent>
-
-    <artifactId>metrics-log4j</artifactId>
-    <name>Metrics Integration for Log4j 1.x</name>
-    <packaging>bundle</packaging>
-    <description>
-        An instrumented appender for Log4j 1.x.
-    </description>
-
-    <dependencies>
-        <dependency>
-            <groupId>io.dropwizard.metrics</groupId>
-            <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>log4j</groupId>
-            <artifactId>log4j</artifactId>
-            <version>1.2.17</version>
-        </dependency>
-    </dependencies>
-</project>
diff --git a/metrics-log4j2/pom.xml b/metrics-log4j2/pom.xml
index 0b975ed..24a1313 100644
--- a/metrics-log4j2/pom.xml
+++ b/metrics-log4j2/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-log4j2</artifactId>
@@ -16,7 +16,8 @@
     </description>
 
     <properties>
-        <log4j2.version>2.3</log4j2.version>
+        <javaModuleName>com.codahale.metrics.log4j2</javaModuleName>
+        <log4j2.version>2.22.1</log4j2.version>
     </properties>
 
     <build>
@@ -32,21 +33,61 @@
         </plugins>
     </build>
 
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>org.apache.logging.log4j</groupId>
             <artifactId>log4j-api</artifactId>
-	    <version>${log4j2.version}</version>
+            <version>${log4j2.version}</version>
         </dependency>
         <dependency>
             <groupId>org.apache.logging.log4j</groupId>
             <artifactId>log4j-core</artifactId>
-	    <version>${log4j2.version}</version>
+            <version>${log4j2.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 </project>
diff --git a/metrics-log4j2/src/main/java/com/codahale/metrics/log4j2/InstrumentedAppender.java b/metrics-log4j2/src/main/java/com/codahale/metrics/log4j2/InstrumentedAppender.java
index 78f4af7..808e542 100644
--- a/metrics-log4j2/src/main/java/com/codahale/metrics/log4j2/InstrumentedAppender.java
+++ b/metrics-log4j2/src/main/java/com/codahale/metrics/log4j2/InstrumentedAppender.java
@@ -24,8 +24,6 @@ import static com.codahale.metrics.MetricRegistry.name;
 @Plugin(name = "MetricsAppender", category = "Core", elementType = "appender")
 public class InstrumentedAppender extends AbstractAppender {
 
-    private static final long serialVersionUID = 1L;
-
     private transient final MetricRegistry registry;
 
     private transient Meter all;
@@ -39,12 +37,11 @@ public class InstrumentedAppender extends AbstractAppender {
     /**
      * Create a new instrumented appender using the given registry name.
      *
-     * @param registryName the name of the registry in {@link SharedMetricRegistries}
-     * @param filter The Filter to associate with the Appender.
-     * @param layout The layout to use to format the event.
+     * @param registryName     the name of the registry in {@link SharedMetricRegistries}
+     * @param filter           The Filter to associate with the Appender.
+     * @param layout           The layout to use to format the event.
      * @param ignoreExceptions If true, exceptions will be logged and suppressed. If false errors will be
-     * logged and then passed to the application.
-     *
+     *                         logged and then passed to the application.
      */
     public InstrumentedAppender(String registryName, Filter filter, Layout<? extends Serializable> layout, boolean ignoreExceptions) {
         this(SharedMetricRegistries.getOrCreate(registryName), filter, layout, ignoreExceptions);
@@ -71,11 +68,11 @@ public class InstrumentedAppender extends AbstractAppender {
     /**
      * Create a new instrumented appender using the given registry.
      *
-     * @param registry the metric registry
-     * @param filter The Filter to associate with the Appender.
-     * @param layout The layout to use to format the event.
+     * @param registry         the metric registry
+     * @param filter           The Filter to associate with the Appender.
+     * @param layout           The layout to use to format the event.
      * @param ignoreExceptions If true, exceptions will be logged and suppressed. If false errors will be
-     * logged and then passed to the application.
+     *                         logged and then passed to the application.
      */
     public InstrumentedAppender(MetricRegistry registry, Filter filter, Layout<? extends Serializable> layout, boolean ignoreExceptions) {
         super(name(Appender.class), filter, layout, ignoreExceptions);
@@ -84,8 +81,9 @@ public class InstrumentedAppender extends AbstractAppender {
 
     /**
      * Create a new instrumented appender using the given appender name and registry.
+     *
      * @param appenderName The name of the appender.
-     * @param registry the metric registry
+     * @param registry     the metric registry
      */
     public InstrumentedAppender(String appenderName, MetricRegistry registry) {
         super(appenderName, null, null, true);
@@ -95,7 +93,7 @@ public class InstrumentedAppender extends AbstractAppender {
     @PluginFactory
     public static InstrumentedAppender createAppender(
             @PluginAttribute("name") String name,
-            @PluginAttribute( value = "registryName", defaultString = "log4j2Metrics") String registry) {
+            @PluginAttribute(value = "registryName", defaultString = "log4j2Metrics") String registry) {
         return new InstrumentedAppender(name, SharedMetricRegistries.getOrCreate(registry));
     }
 
diff --git a/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderConfigTest.java b/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderConfigTest.java
index b17d9ed..ea546b0 100644
--- a/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderConfigTest.java
+++ b/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderConfigTest.java
@@ -13,53 +13,54 @@ import org.junit.Test;
 import static org.assertj.core.api.Assertions.assertThat;
 
 public class InstrumentedAppenderConfigTest {
-  public static final String METRIC_NAME_PREFIX = "metrics";
-  public static final String REGISTRY_NAME = "shared-metrics-registry";
+    public static final String METRIC_NAME_PREFIX = "metrics";
+    public static final String REGISTRY_NAME = "shared-metrics-registry";
 
-  private final MetricRegistry registry = SharedMetricRegistries.getOrCreate(REGISTRY_NAME);
-  private ConfigurationSource source;
-  private LoggerContext context;
+    private final MetricRegistry registry = SharedMetricRegistries.getOrCreate(REGISTRY_NAME);
+    private ConfigurationSource source;
+    private LoggerContext context;
 
-  @Before
-  public void setUp() throws Exception {
-    source = new ConfigurationSource(this.getClass().getClassLoader().getResourceAsStream("log4j2-testconfig.xml"));
-    context = Configurator.initialize(null, source);
-  }
-  @After
-  public void tearDown() throws Exception {
-    context.stop();
-  }
+    @Before
+    public void setUp() throws Exception {
+        source = new ConfigurationSource(this.getClass().getClassLoader().getResourceAsStream("log4j2-testconfig.xml"));
+        context = Configurator.initialize(null, source);
+    }
 
-  // The biggest test is that we can initialize the log4j2 config at all.
+    @After
+    public void tearDown() {
+        context.stop();
+    }
 
-  @Test
-  public void canRecordAll() throws Exception {
-    Logger logger = context.getLogger(this.getClass().getName());
+    // The biggest test is that we can initialize the log4j2 config at all.
 
-    long initialAllCount = registry.meter(METRIC_NAME_PREFIX + ".all").getCount();
-    logger.error("an error message");
-    assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
-            .isEqualTo(initialAllCount + 1);
-  }
+    @Test
+    public void canRecordAll() {
+        Logger logger = context.getLogger(this.getClass().getName());
 
-  @Test
-  public void canRecordError() throws Exception {
-    Logger logger = context.getLogger(this.getClass().getName());
+        long initialAllCount = registry.meter(METRIC_NAME_PREFIX + ".all").getCount();
+        logger.error("an error message");
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
+                .isEqualTo(initialAllCount + 1);
+    }
 
-    long initialErrorCount = registry.meter(METRIC_NAME_PREFIX + ".error").getCount();
-    logger.error("an error message");
-    assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
-            .isEqualTo(initialErrorCount + 1);
-  }
+    @Test
+    public void canRecordError() {
+        Logger logger = context.getLogger(this.getClass().getName());
 
-  @Test
-  public void noInvalidRecording() throws Exception {
-    Logger logger = context.getLogger(this.getClass().getName());
+        long initialErrorCount = registry.meter(METRIC_NAME_PREFIX + ".error").getCount();
+        logger.error("an error message");
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
+                .isEqualTo(initialErrorCount + 1);
+    }
 
-    long initialInfoCount = registry.meter(METRIC_NAME_PREFIX + ".info").getCount();
-    logger.error("an error message");
-    assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
-            .isEqualTo(initialInfoCount);
-  }
+    @Test
+    public void noInvalidRecording() {
+        Logger logger = context.getLogger(this.getClass().getName());
+
+        long initialInfoCount = registry.meter(METRIC_NAME_PREFIX + ".info").getCount();
+        logger.error("an error message");
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(initialInfoCount);
+    }
 
 }
diff --git a/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderTest.java b/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderTest.java
index c32cc6c..ee617c3 100644
--- a/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderTest.java
+++ b/metrics-log4j2/src/test/java/com/codahale/metrics/log4j2/InstrumentedAppenderTest.java
@@ -2,7 +2,6 @@ package com.codahale.metrics.log4j2;
 
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.SharedMetricRegistries;
-import com.codahale.metrics.log4j2.InstrumentedAppender;
 
 import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.core.LogEvent;
@@ -23,17 +22,17 @@ public class InstrumentedAppenderTest {
     private final LogEvent event = mock(LogEvent.class);
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         appender.start();
     }
 
     @After
-    public void tearDown() throws Exception {
+    public void tearDown() {
         SharedMetricRegistries.clear();
     }
 
     @Test
-    public void metersTraceEvents() throws Exception {
+    public void metersTraceEvents() {
         when(event.getLevel()).thenReturn(Level.TRACE);
 
         appender.append(event);
@@ -46,7 +45,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersDebugEvents() throws Exception {
+    public void metersDebugEvents() {
         when(event.getLevel()).thenReturn(Level.DEBUG);
 
         appender.append(event);
@@ -59,7 +58,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersInfoEvents() throws Exception {
+    public void metersInfoEvents() {
         when(event.getLevel()).thenReturn(Level.INFO);
 
         appender.append(event);
@@ -72,7 +71,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersWarnEvents() throws Exception {
+    public void metersWarnEvents() {
         when(event.getLevel()).thenReturn(Level.WARN);
 
         appender.append(event);
@@ -85,7 +84,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersErrorEvents() throws Exception {
+    public void metersErrorEvents() {
         when(event.getLevel()).thenReturn(Level.ERROR);
 
         appender.append(event);
@@ -98,7 +97,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersFatalEvents() throws Exception {
+    public void metersFatalEvents() {
         when(event.getLevel()).thenReturn(Level.FATAL);
 
         appender.append(event);
@@ -111,7 +110,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void usesSharedRegistries() throws Exception {
+    public void usesSharedRegistries() {
 
         String registryName = "registry";
 
diff --git a/metrics-log4j2/src/test/resources/log4j2-testconfig.xml b/metrics-log4j2/src/test/resources/log4j2-testconfig.xml
index 676eca4..aaa2aa2 100644
--- a/metrics-log4j2/src/test/resources/log4j2-testconfig.xml
+++ b/metrics-log4j2/src/test/resources/log4j2-testconfig.xml
@@ -5,7 +5,7 @@
     </Appenders>
     <Loggers>
         <Root level="INFO">
-            <AppenderRef ref="metrics" />
+            <AppenderRef ref="metrics"/>
         </Root>
     </Loggers>
 </Configuration>
diff --git a/metrics-logback/pom.xml b/metrics-logback/pom.xml
index d8a26f0..a86eb93 100644
--- a/metrics-logback/pom.xml
+++ b/metrics-logback/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-logback</artifactId>
@@ -16,19 +16,71 @@
     </description>
 
     <properties>
-        <logback.version>1.1.10</logback.version>
+        <javaModuleName>com.codahale.metrics.logback</javaModuleName>
+        <logback.version>1.2.13</logback.version>
     </properties>
 
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-core</artifactId>
+            <version>${logback.version}</version>
         </dependency>
         <dependency>
             <groupId>ch.qos.logback</groupId>
             <artifactId>logback-classic</artifactId>
             <version>${logback.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 </project>
diff --git a/metrics-logback/src/main/java/com/codahale/metrics/logback/InstrumentedAppender.java b/metrics-logback/src/main/java/com/codahale/metrics/logback/InstrumentedAppender.java
index 2b5f5f1..9293fe3 100644
--- a/metrics-logback/src/main/java/com/codahale/metrics/logback/InstrumentedAppender.java
+++ b/metrics-logback/src/main/java/com/codahale/metrics/logback/InstrumentedAppender.java
@@ -30,11 +30,11 @@ public class InstrumentedAppender extends UnsynchronizedAppenderBase<ILoggingEve
 
     /**
      * Create a new instrumented appender using the given registry name.
-     *
      */
     public InstrumentedAppender() {
-      this(System.getProperty(REGISTRY_PROPERTY_NAME, DEFAULT_REGISTRY));
+        this(System.getProperty(REGISTRY_PROPERTY_NAME, DEFAULT_REGISTRY));
     }
+
     /**
      * Create a new instrumented appender using the given registry name.
      *
diff --git a/metrics-logback/src/test/java/com/codahale/metrics/logback/InstrumentedAppenderTest.java b/metrics-logback/src/test/java/com/codahale/metrics/logback/InstrumentedAppenderTest.java
index 71c81f4..2b46e2a 100644
--- a/metrics-logback/src/test/java/com/codahale/metrics/logback/InstrumentedAppenderTest.java
+++ b/metrics-logback/src/test/java/com/codahale/metrics/logback/InstrumentedAppenderTest.java
@@ -22,17 +22,17 @@ public class InstrumentedAppenderTest {
     private final ILoggingEvent event = mock(ILoggingEvent.class);
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         appender.start();
     }
 
     @After
-    public void tearDown() throws Exception {
+    public void tearDown() {
         SharedMetricRegistries.clear();
     }
 
     @Test
-    public void metersTraceEvents() throws Exception {
+    public void metersTraceEvents() {
         when(event.getLevel()).thenReturn(Level.TRACE);
 
         appender.doAppend(event);
@@ -45,7 +45,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersDebugEvents() throws Exception {
+    public void metersDebugEvents() {
         when(event.getLevel()).thenReturn(Level.DEBUG);
 
         appender.doAppend(event);
@@ -58,7 +58,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersInfoEvents() throws Exception {
+    public void metersInfoEvents() {
         when(event.getLevel()).thenReturn(Level.INFO);
 
         appender.doAppend(event);
@@ -71,7 +71,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersWarnEvents() throws Exception {
+    public void metersWarnEvents() {
         when(event.getLevel()).thenReturn(Level.WARN);
 
         appender.doAppend(event);
@@ -84,7 +84,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersErrorEvents() throws Exception {
+    public void metersErrorEvents() {
         when(event.getLevel()).thenReturn(Level.ERROR);
 
         appender.doAppend(event);
@@ -97,7 +97,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void usesSharedRegistries() throws Exception {
+    public void usesSharedRegistries() {
 
         String registryName = "registry";
 
@@ -114,30 +114,30 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void usesDefaultRegistry() throws Exception {
-      SharedMetricRegistries.add(InstrumentedAppender.DEFAULT_REGISTRY, registry);
-      final InstrumentedAppender shared = new InstrumentedAppender();
-      shared.start();
-      when(event.getLevel()).thenReturn(Level.INFO);
-      shared.doAppend(event);
-
-      assertThat(SharedMetricRegistries.names().contains(InstrumentedAppender.DEFAULT_REGISTRY));
-      assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
-              .isEqualTo(1);
+    public void usesDefaultRegistry() {
+        SharedMetricRegistries.add(InstrumentedAppender.DEFAULT_REGISTRY, registry);
+        final InstrumentedAppender shared = new InstrumentedAppender();
+        shared.start();
+        when(event.getLevel()).thenReturn(Level.INFO);
+        shared.doAppend(event);
+
+        assertThat(SharedMetricRegistries.names()).contains(InstrumentedAppender.DEFAULT_REGISTRY);
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(1);
     }
 
     @Test
-    public void usesRegistryFromProperty() throws Exception {
-      SharedMetricRegistries.add("something_else", registry);
-      System.setProperty(InstrumentedAppender.REGISTRY_PROPERTY_NAME, "something_else");
-      final InstrumentedAppender shared = new InstrumentedAppender();
-      shared.start();
-      when(event.getLevel()).thenReturn(Level.INFO);
-      shared.doAppend(event);
-
-      assertThat(SharedMetricRegistries.names().contains("something_else"));
-      assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
-              .isEqualTo(1);
+    public void usesRegistryFromProperty() {
+        SharedMetricRegistries.add("something_else", registry);
+        System.setProperty(InstrumentedAppender.REGISTRY_PROPERTY_NAME, "something_else");
+        final InstrumentedAppender shared = new InstrumentedAppender();
+        shared.start();
+        when(event.getLevel()).thenReturn(Level.INFO);
+        shared.doAppend(event);
+
+        assertThat(SharedMetricRegistries.names()).contains("something_else");
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(1);
     }
 
 }
diff --git a/metrics-logback13/pom.xml b/metrics-logback13/pom.xml
new file mode 100644
index 0000000..84b3f7b
--- /dev/null
+++ b/metrics-logback13/pom.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-logback13</artifactId>
+    <name>Metrics Integration for Logback 1.3.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        An instrumented appender for Logback 1.3.x.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.logback13</javaModuleName>
+        <logback13.version>1.3.14</logback13.version>
+        <slf4j.version>2.0.11</slf4j.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-core</artifactId>
+            <version>${logback13.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <version>${logback13.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-log4j/src/main/java/com/codahale/metrics/log4j/InstrumentedAppender.java b/metrics-logback13/src/main/java/io/dropwizard/metrics/logback13/InstrumentedAppender.java
similarity index 70%
rename from metrics-log4j/src/main/java/com/codahale/metrics/log4j/InstrumentedAppender.java
rename to metrics-logback13/src/main/java/io/dropwizard/metrics/logback13/InstrumentedAppender.java
index 4d424a8..ab23fe3 100644
--- a/metrics-log4j/src/main/java/com/codahale/metrics/log4j/InstrumentedAppender.java
+++ b/metrics-logback13/src/main/java/io/dropwizard/metrics/logback13/InstrumentedAppender.java
@@ -1,22 +1,24 @@
-package com.codahale.metrics.log4j;
+package io.dropwizard.metrics.logback13;
 
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.Appender;
+import ch.qos.logback.core.UnsynchronizedAppenderBase;
 import com.codahale.metrics.Meter;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.SharedMetricRegistries;
-import org.apache.log4j.Appender;
-import org.apache.log4j.AppenderSkeleton;
-import org.apache.log4j.Level;
-import org.apache.log4j.spi.LoggingEvent;
 
 import static com.codahale.metrics.MetricRegistry.name;
 
 /**
- * A Log4J {@link Appender} which has seven meters, one for each logging level and one for the total
+ * A Logback {@link Appender} which has six meters, one for each logging level and one for the total
  * number of statements being logged. The meter names are the logging level names appended to the
  * name of the appender.
  */
-public class InstrumentedAppender extends AppenderSkeleton {
+public class InstrumentedAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
     private final MetricRegistry registry;
+    public static final String DEFAULT_REGISTRY = "logback-metrics";
+    public static final String REGISTRY_PROPERTY_NAME = "metrics.logback.registry";
 
     private Meter all;
     private Meter trace;
@@ -24,7 +26,14 @@ public class InstrumentedAppender extends AppenderSkeleton {
     private Meter info;
     private Meter warn;
     private Meter error;
-    private Meter fatal;
+
+
+    /**
+     * Create a new instrumented appender using the given registry name.
+     */
+    public InstrumentedAppender() {
+        this(System.getProperty(REGISTRY_PROPERTY_NAME, DEFAULT_REGISTRY));
+    }
 
     /**
      * Create a new instrumented appender using the given registry name.
@@ -42,22 +51,22 @@ public class InstrumentedAppender extends AppenderSkeleton {
      */
     public InstrumentedAppender(MetricRegistry registry) {
         this.registry = registry;
-        setName(name(Appender.class));
+        setName(Appender.class.getName());
     }
 
     @Override
-    public void activateOptions() {
+    public void start() {
         this.all = registry.meter(name(getName(), "all"));
         this.trace = registry.meter(name(getName(), "trace"));
         this.debug = registry.meter(name(getName(), "debug"));
         this.info = registry.meter(name(getName(), "info"));
         this.warn = registry.meter(name(getName(), "warn"));
         this.error = registry.meter(name(getName(), "error"));
-        this.fatal = registry.meter(name(getName(), "fatal"));
+        super.start();
     }
 
     @Override
-    protected void append(LoggingEvent event) {
+    protected void append(ILoggingEvent event) {
         all.mark();
         switch (event.getLevel().toInt()) {
             case Level.TRACE_INT:
@@ -75,21 +84,8 @@ public class InstrumentedAppender extends AppenderSkeleton {
             case Level.ERROR_INT:
                 error.mark();
                 break;
-            case Level.FATAL_INT:
-                fatal.mark();
-                break;
             default:
                 break;
         }
     }
-
-    @Override
-    public void close() {
-        // nothing doing
-    }
-
-    @Override
-    public boolean requiresLayout() {
-        return false;
-    }
 }
diff --git a/metrics-log4j/src/test/java/com/codahale/metrics/log4j/InstrumentedAppenderTest.java b/metrics-logback13/src/test/java/io/dropwizard/metrics/logback13/InstrumentedAppenderTest.java
similarity index 61%
rename from metrics-log4j/src/test/java/com/codahale/metrics/log4j/InstrumentedAppenderTest.java
rename to metrics-logback13/src/test/java/io/dropwizard/metrics/logback13/InstrumentedAppenderTest.java
index 2550fe5..9c3600d 100644
--- a/metrics-log4j/src/test/java/com/codahale/metrics/log4j/InstrumentedAppenderTest.java
+++ b/metrics-logback13/src/test/java/io/dropwizard/metrics/logback13/InstrumentedAppenderTest.java
@@ -1,9 +1,9 @@
-package com.codahale.metrics.log4j;
+package io.dropwizard.metrics.logback13;
 
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.SharedMetricRegistries;
-import org.apache.log4j.Level;
-import org.apache.log4j.spi.LoggingEvent;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -14,24 +14,24 @@ import static org.mockito.Mockito.when;
 
 public class InstrumentedAppenderTest {
 
-    public static final String METRIC_NAME_PREFIX = "org.apache.log4j.Appender";
+    public static final String METRIC_NAME_PREFIX = "ch.qos.logback.core.Appender";
 
     private final MetricRegistry registry = new MetricRegistry();
     private final InstrumentedAppender appender = new InstrumentedAppender(registry);
-    private final LoggingEvent event = mock(LoggingEvent.class);
+    private final ILoggingEvent event = mock(ILoggingEvent.class);
 
     @Before
-    public void setUp() throws Exception {
-        appender.activateOptions();
+    public void setUp() {
+        appender.start();
     }
 
     @After
-    public void tearDown() throws Exception {
+    public void tearDown() {
         SharedMetricRegistries.clear();
     }
 
     @Test
-    public void metersTraceEvents() throws Exception {
+    public void metersTraceEvents() {
         when(event.getLevel()).thenReturn(Level.TRACE);
 
         appender.doAppend(event);
@@ -44,7 +44,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersDebugEvents() throws Exception {
+    public void metersDebugEvents() {
         when(event.getLevel()).thenReturn(Level.DEBUG);
 
         appender.doAppend(event);
@@ -57,7 +57,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersInfoEvents() throws Exception {
+    public void metersInfoEvents() {
         when(event.getLevel()).thenReturn(Level.INFO);
 
         appender.doAppend(event);
@@ -70,7 +70,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersWarnEvents() throws Exception {
+    public void metersWarnEvents() {
         when(event.getLevel()).thenReturn(Level.WARN);
 
         appender.doAppend(event);
@@ -83,7 +83,7 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersErrorEvents() throws Exception {
+    public void metersErrorEvents() {
         when(event.getLevel()).thenReturn(Level.ERROR);
 
         appender.doAppend(event);
@@ -96,32 +96,47 @@ public class InstrumentedAppenderTest {
     }
 
     @Test
-    public void metersFatalEvents() throws Exception {
-        when(event.getLevel()).thenReturn(Level.FATAL);
+    public void usesSharedRegistries() {
 
-        appender.doAppend(event);
+        String registryName = "registry";
 
-        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
-                .isEqualTo(1);
+        SharedMetricRegistries.add(registryName, registry);
+        final InstrumentedAppender shared = new InstrumentedAppender(registryName);
+        shared.start();
 
-        assertThat(registry.meter(METRIC_NAME_PREFIX + ".fatal").getCount())
+        when(event.getLevel()).thenReturn(Level.INFO);
+
+        shared.doAppend(event);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
                 .isEqualTo(1);
     }
 
     @Test
-    public void usesSharedRegistries() throws Exception {
-        String registryName = "registry";
-
-        SharedMetricRegistries.add(registryName, registry);
+    public void usesDefaultRegistry() {
+        SharedMetricRegistries.add(InstrumentedAppender.DEFAULT_REGISTRY, registry);
+        final InstrumentedAppender shared = new InstrumentedAppender();
+        shared.start();
+        when(event.getLevel()).thenReturn(Level.INFO);
+        shared.doAppend(event);
 
-        final InstrumentedAppender shared = new InstrumentedAppender(registryName);
-        shared.activateOptions();
+        assertThat(SharedMetricRegistries.names()).contains(InstrumentedAppender.DEFAULT_REGISTRY);
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(1);
+    }
 
+    @Test
+    public void usesRegistryFromProperty() {
+        SharedMetricRegistries.add("something_else", registry);
+        System.setProperty(InstrumentedAppender.REGISTRY_PROPERTY_NAME, "something_else");
+        final InstrumentedAppender shared = new InstrumentedAppender();
+        shared.start();
         when(event.getLevel()).thenReturn(Level.INFO);
-
         shared.doAppend(event);
 
+        assertThat(SharedMetricRegistries.names()).contains("something_else");
         assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
                 .isEqualTo(1);
     }
+
 }
diff --git a/metrics-logback14/pom.xml b/metrics-logback14/pom.xml
new file mode 100644
index 0000000..f64ad8c
--- /dev/null
+++ b/metrics-logback14/pom.xml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>io.dropwizard.metrics</groupId>
+        <artifactId>metrics-parent</artifactId>
+        <version>4.2.25</version>
+    </parent>
+
+    <artifactId>metrics-logback14</artifactId>
+    <name>Metrics Integration for Logback 1.4.x</name>
+    <packaging>bundle</packaging>
+    <description>
+        An instrumented appender for Logback 1.4.x.
+    </description>
+
+    <properties>
+        <javaModuleName>io.dropwizard.metrics.logback14</javaModuleName>
+        <logback14.version>1.4.14</logback14.version>
+
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>io.dropwizard.metrics</groupId>
+            <artifactId>metrics-core</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-core</artifactId>
+            <version>${logback14.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <version>${logback14.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/metrics-logback14/src/main/java/io/dropwizard/metrics/logback14/InstrumentedAppender.java b/metrics-logback14/src/main/java/io/dropwizard/metrics/logback14/InstrumentedAppender.java
new file mode 100644
index 0000000..e41f95b
--- /dev/null
+++ b/metrics-logback14/src/main/java/io/dropwizard/metrics/logback14/InstrumentedAppender.java
@@ -0,0 +1,91 @@
+package io.dropwizard.metrics.logback14;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.Appender;
+import ch.qos.logback.core.UnsynchronizedAppenderBase;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.SharedMetricRegistries;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+/**
+ * A Logback {@link Appender} which has six meters, one for each logging level and one for the total
+ * number of statements being logged. The meter names are the logging level names appended to the
+ * name of the appender.
+ */
+public class InstrumentedAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
+    private final MetricRegistry registry;
+    public static final String DEFAULT_REGISTRY = "logback-metrics";
+    public static final String REGISTRY_PROPERTY_NAME = "metrics.logback.registry";
+
+    private Meter all;
+    private Meter trace;
+    private Meter debug;
+    private Meter info;
+    private Meter warn;
+    private Meter error;
+
+
+    /**
+     * Create a new instrumented appender using the given registry name.
+     */
+    public InstrumentedAppender() {
+        this(System.getProperty(REGISTRY_PROPERTY_NAME, DEFAULT_REGISTRY));
+    }
+
+    /**
+     * Create a new instrumented appender using the given registry name.
+     *
+     * @param registryName the name of the registry in {@link SharedMetricRegistries}
+     */
+    public InstrumentedAppender(String registryName) {
+        this(SharedMetricRegistries.getOrCreate(registryName));
+    }
+
+    /**
+     * Create a new instrumented appender using the given registry.
+     *
+     * @param registry the metric registry
+     */
+    public InstrumentedAppender(MetricRegistry registry) {
+        this.registry = registry;
+        setName(Appender.class.getName());
+    }
+
+    @Override
+    public void start() {
+        this.all = registry.meter(name(getName(), "all"));
+        this.trace = registry.meter(name(getName(), "trace"));
+        this.debug = registry.meter(name(getName(), "debug"));
+        this.info = registry.meter(name(getName(), "info"));
+        this.warn = registry.meter(name(getName(), "warn"));
+        this.error = registry.meter(name(getName(), "error"));
+        super.start();
+    }
+
+    @Override
+    protected void append(ILoggingEvent event) {
+        all.mark();
+        switch (event.getLevel().toInt()) {
+            case Level.TRACE_INT:
+                trace.mark();
+                break;
+            case Level.DEBUG_INT:
+                debug.mark();
+                break;
+            case Level.INFO_INT:
+                info.mark();
+                break;
+            case Level.WARN_INT:
+                warn.mark();
+                break;
+            case Level.ERROR_INT:
+                error.mark();
+                break;
+            default:
+                break;
+        }
+    }
+}
diff --git a/metrics-logback14/src/test/java/io/dropwizard/metrics/logback14/InstrumentedAppenderTest.java b/metrics-logback14/src/test/java/io/dropwizard/metrics/logback14/InstrumentedAppenderTest.java
new file mode 100644
index 0000000..ce0c570
--- /dev/null
+++ b/metrics-logback14/src/test/java/io/dropwizard/metrics/logback14/InstrumentedAppenderTest.java
@@ -0,0 +1,142 @@
+package io.dropwizard.metrics.logback14;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.SharedMetricRegistries;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class InstrumentedAppenderTest {
+
+    public static final String METRIC_NAME_PREFIX = "ch.qos.logback.core.Appender";
+
+    private final MetricRegistry registry = new MetricRegistry();
+    private final InstrumentedAppender appender = new InstrumentedAppender(registry);
+    private final ILoggingEvent event = mock(ILoggingEvent.class);
+
+    @Before
+    public void setUp() {
+        appender.start();
+    }
+
+    @After
+    public void tearDown() {
+        SharedMetricRegistries.clear();
+    }
+
+    @Test
+    public void metersTraceEvents() {
+        when(event.getLevel()).thenReturn(Level.TRACE);
+
+        appender.doAppend(event);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
+                .isEqualTo(1);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".trace").getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void metersDebugEvents() {
+        when(event.getLevel()).thenReturn(Level.DEBUG);
+
+        appender.doAppend(event);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
+                .isEqualTo(1);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".debug").getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void metersInfoEvents() {
+        when(event.getLevel()).thenReturn(Level.INFO);
+
+        appender.doAppend(event);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
+                .isEqualTo(1);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void metersWarnEvents() {
+        when(event.getLevel()).thenReturn(Level.WARN);
+
+        appender.doAppend(event);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
+                .isEqualTo(1);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".warn").getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void metersErrorEvents() {
+        when(event.getLevel()).thenReturn(Level.ERROR);
+
+        appender.doAppend(event);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".all").getCount())
+                .isEqualTo(1);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".error").getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void usesSharedRegistries() {
+
+        String registryName = "registry";
+
+        SharedMetricRegistries.add(registryName, registry);
+        final InstrumentedAppender shared = new InstrumentedAppender(registryName);
+        shared.start();
+
+        when(event.getLevel()).thenReturn(Level.INFO);
+
+        shared.doAppend(event);
+
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void usesDefaultRegistry() {
+        SharedMetricRegistries.add(InstrumentedAppender.DEFAULT_REGISTRY, registry);
+        final InstrumentedAppender shared = new InstrumentedAppender();
+        shared.start();
+        when(event.getLevel()).thenReturn(Level.INFO);
+        shared.doAppend(event);
+
+        assertThat(SharedMetricRegistries.names()).contains(InstrumentedAppender.DEFAULT_REGISTRY);
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(1);
+    }
+
+    @Test
+    public void usesRegistryFromProperty() {
+        SharedMetricRegistries.add("something_else", registry);
+        System.setProperty(InstrumentedAppender.REGISTRY_PROPERTY_NAME, "something_else");
+        final InstrumentedAppender shared = new InstrumentedAppender();
+        shared.start();
+        when(event.getLevel()).thenReturn(Level.INFO);
+        shared.doAppend(event);
+
+        assertThat(SharedMetricRegistries.names()).contains("something_else");
+        assertThat(registry.meter(METRIC_NAME_PREFIX + ".info").getCount())
+                .isEqualTo(1);
+    }
+
+}
diff --git a/metrics-servlet/pom.xml b/metrics-servlet/pom.xml
index d5650a3..bca8ac4 100644
--- a/metrics-servlet/pom.xml
+++ b/metrics-servlet/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-servlet</artifactId>
@@ -15,11 +15,27 @@
         An instrumented filter for servlet environments.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.servlet</javaModuleName>
+        <servlet.version>4.0.1</servlet.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>javax.servlet</groupId>
@@ -27,5 +43,23 @@
             <version>${servlet.version}</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/metrics-servlet/src/main/java/com/codahale/metrics/servlet/AbstractInstrumentedFilter.java b/metrics-servlet/src/main/java/com/codahale/metrics/servlet/AbstractInstrumentedFilter.java
index c06dac9..e7bcb37 100644
--- a/metrics-servlet/src/main/java/com/codahale/metrics/servlet/AbstractInstrumentedFilter.java
+++ b/metrics-servlet/src/main/java/com/codahale/metrics/servlet/AbstractInstrumentedFilter.java
@@ -5,7 +5,14 @@ import com.codahale.metrics.Meter;
 import com.codahale.metrics.MetricRegistry;
 import com.codahale.metrics.Timer;
 
-import javax.servlet.*;
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
 import java.io.IOException;
@@ -58,26 +65,20 @@ public abstract class AbstractInstrumentedFilter implements Filter {
         final MetricRegistry metricsRegistry = getMetricsFactory(filterConfig);
 
         String metricName = filterConfig.getInitParameter(METRIC_PREFIX);
-        if(metricName == null || metricName.isEmpty()) {
+        if (metricName == null || metricName.isEmpty()) {
             metricName = getClass().getName();
         }
 
-        this.metersByStatusCode = new ConcurrentHashMap<Integer, Meter>(meterNamesByStatusCode
-                .size());
+        this.metersByStatusCode = new ConcurrentHashMap<>(meterNamesByStatusCode.size());
         for (Entry<Integer, String> entry : meterNamesByStatusCode.entrySet()) {
             metersByStatusCode.put(entry.getKey(),
                     metricsRegistry.meter(name(metricName, entry.getValue())));
         }
-        this.otherMeter = metricsRegistry.meter(name(metricName,
-                                                     otherMetricName));
-        this.timeoutsMeter = metricsRegistry.meter(name(metricName,
-                                                        "timeouts"));
-        this.errorsMeter = metricsRegistry.meter(name(metricName,
-                                                      "errors"));
-        this.activeRequests = metricsRegistry.counter(name(metricName,
-                                                           "activeRequests"));
-        this.requestTimer = metricsRegistry.timer(name(metricName,
-                                                       "requests"));
+        this.otherMeter = metricsRegistry.meter(name(metricName, otherMetricName));
+        this.timeoutsMeter = metricsRegistry.meter(name(metricName, "timeouts"));
+        this.errorsMeter = metricsRegistry.meter(name(metricName, "errors"));
+        this.activeRequests = metricsRegistry.counter(name(metricName, "activeRequests"));
+        this.requestTimer = metricsRegistry.timer(name(metricName, "requests"));
 
     }
 
@@ -95,7 +96,7 @@ public abstract class AbstractInstrumentedFilter implements Filter {
 
     @Override
     public void destroy() {
-        
+
     }
 
     @Override
@@ -109,13 +110,7 @@ public abstract class AbstractInstrumentedFilter implements Filter {
         boolean error = false;
         try {
             chain.doFilter(request, wrappedResponse);
-        } catch (IOException e) {
-            error = true;
-            throw e;
-        } catch (ServletException e) {
-            error = true;
-            throw e;
-        } catch (RuntimeException e) {
+        } catch (IOException | RuntimeException | ServletException e) {
             error = true;
             throw e;
         } finally {
@@ -169,11 +164,13 @@ public abstract class AbstractInstrumentedFilter implements Filter {
         }
 
         @Override
+        @SuppressWarnings("deprecation")
         public void setStatus(int sc, String sm) {
             httpStatus = sc;
             super.setStatus(sc, sm);
         }
 
+        @Override
         public int getStatus() {
             return httpStatus;
         }
diff --git a/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilter.java b/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilter.java
index 5333344..f97aa36 100644
--- a/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilter.java
+++ b/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilter.java
@@ -5,7 +5,7 @@ import java.util.Map;
 
 /**
  * Implementation of the {@link AbstractInstrumentedFilter} which provides a default set of response codes
- * to capture information about. <p>Use it in your servlet.xml like this:</p>
+ * to capture information about. <p>Use it in your servlet.xml like this:<p>
  * <pre>{@code
  * <filter>
  *     <filter-name>instrumentedFilter</filter-name>
@@ -36,7 +36,7 @@ public class InstrumentedFilter extends AbstractInstrumentedFilter {
     }
 
     private static Map<Integer, String> createMeterNamesByStatusCode() {
-        final Map<Integer, String> meterNamesByStatusCode = new HashMap<Integer, String>(6);
+        final Map<Integer, String> meterNamesByStatusCode = new HashMap<>(6);
         meterNamesByStatusCode.put(OK, NAME_PREFIX + "ok");
         meterNamesByStatusCode.put(CREATED, NAME_PREFIX + "created");
         meterNamesByStatusCode.put(NO_CONTENT, NAME_PREFIX + "noContent");
diff --git a/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilterContextListener.java b/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilterContextListener.java
index 8cdc01e..3a009ed 100644
--- a/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilterContextListener.java
+++ b/metrics-servlet/src/main/java/com/codahale/metrics/servlet/InstrumentedFilterContextListener.java
@@ -18,8 +18,7 @@ public abstract class InstrumentedFilterContextListener implements ServletContex
 
     @Override
     public void contextInitialized(ServletContextEvent sce) {
-        sce.getServletContext().setAttribute(InstrumentedFilter.REGISTRY_ATTRIBUTE,
-                                             getMetricRegistry());
+        sce.getServletContext().setAttribute(InstrumentedFilter.REGISTRY_ATTRIBUTE, getMetricRegistry());
     }
 
     @Override
diff --git a/metrics-servlet/src/test/java/com/codahale/metrics/servlet/InstrumentedFilterContextListenerTest.java b/metrics-servlet/src/test/java/com/codahale/metrics/servlet/InstrumentedFilterContextListenerTest.java
index 576d889..d94a84a 100644
--- a/metrics-servlet/src/test/java/com/codahale/metrics/servlet/InstrumentedFilterContextListenerTest.java
+++ b/metrics-servlet/src/test/java/com/codahale/metrics/servlet/InstrumentedFilterContextListenerTest.java
@@ -20,7 +20,7 @@ public class InstrumentedFilterContextListenerTest {
     };
 
     @Test
-    public void injectsTheMetricRegistryIntoTheServletContext() throws Exception {
+    public void injectsTheMetricRegistryIntoTheServletContext() {
         final ServletContext context = mock(ServletContext.class);
 
         final ServletContextEvent event = mock(ServletContextEvent.class);
diff --git a/metrics-servlets/pom.xml b/metrics-servlets/pom.xml
index 898c77f..ee64a50 100644
--- a/metrics-servlets/pom.xml
+++ b/metrics-servlets/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>io.dropwizard.metrics</groupId>
         <artifactId>metrics-parent</artifactId>
-        <version>3.2.6</version>
+        <version>4.2.25</version>
     </parent>
 
     <artifactId>metrics-servlets</artifactId>
@@ -16,31 +16,58 @@
         your production environment.
     </description>
 
+    <properties>
+        <javaModuleName>com.codahale.metrics.servlets</javaModuleName>
+        <papertrail.profiler.version>1.1.1</papertrail.profiler.version>
+        <servlet.version>4.0.1</servlet.version>
+        <jackson.version>2.12.7.1</jackson.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>io.dropwizard.metrics</groupId>
+                <artifactId>metrics-bom</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-bom</artifactId>
+                <version>${jetty9.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>net.bytebuddy</groupId>
+                <artifactId>byte-buddy</artifactId>
+                <version>${byte-buddy.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
     <dependencies>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-core</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-healthchecks</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-json</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-jvm</artifactId>
-            <version>${project.version}</version>
         </dependency>
         <dependency>
-            <groupId>com.papertrail</groupId>
+            <groupId>com.helger</groupId>
             <artifactId>profiler</artifactId>
-            <version>1.0.2</version>
+            <version>${papertrail.profiler.version}</version>
         </dependency>
         <dependency>
             <groupId>javax.servlet</groupId>
@@ -53,16 +80,67 @@
             <artifactId>jackson-databind</artifactId>
             <version>${jackson.version}</version>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-servlet</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+            <classifier>tests</classifier>
+            <version>${jetty9.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-server</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-util</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.eclipse.jetty</groupId>
             <artifactId>jetty-servlet</artifactId>
+            <classifier>tests</classifier>
             <version>${jetty9.version}</version>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>io.dropwizard.metrics</groupId>
             <artifactId>metrics-jetty9</artifactId>
-            <version>${project.version}</version>
             <scope>test</scope>
         </dependency>
     </dependencies>
diff --git a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/AdminServlet.java b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/AdminServlet.java
index a3300d4..342c72c 100755
--- a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/AdminServlet.java
+++ b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/AdminServlet.java
@@ -1,6 +1,7 @@
 package com.codahale.metrics.servlets;
 
 import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -16,14 +17,19 @@ public class AdminServlet extends HttpServlet {
     public static final String DEFAULT_THREADS_URI = "/threads";
     public static final String DEFAULT_CPU_PROFILE_URI = "/pprof";
 
+    public static final String METRICS_ENABLED_PARAM_KEY = "metrics-enabled";
     public static final String METRICS_URI_PARAM_KEY = "metrics-uri";
+    public static final String PING_ENABLED_PARAM_KEY = "ping-enabled";
     public static final String PING_URI_PARAM_KEY = "ping-uri";
+    public static final String THREADS_ENABLED_PARAM_KEY = "threads-enabled";
     public static final String THREADS_URI_PARAM_KEY = "threads-uri";
+    public static final String HEALTHCHECK_ENABLED_PARAM_KEY = "healthcheck-enabled";
     public static final String HEALTHCHECK_URI_PARAM_KEY = "healthcheck-uri";
-    public static final String SERVICE_NAME_PARAM_KEY= "service-name";
+    public static final String SERVICE_NAME_PARAM_KEY = "service-name";
+    public static final String CPU_PROFILE_ENABLED_PARAM_KEY = "cpu-profile-enabled";
     public static final String CPU_PROFILE_URI_PARAM_KEY = "cpu-profile-uri";
 
-    private static final String TEMPLATE = String.format(
+    private static final String BASE_TEMPLATE =
             "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"%n" +
                     "        \"http://www.w3.org/TR/html4/loose.dtd\">%n" +
                     "<html>%n" +
@@ -33,16 +39,18 @@ public class AdminServlet extends HttpServlet {
                     "<body>%n" +
                     "  <h1>Operational Menu{10}</h1>%n" +
                     "  <ul>%n" +
-                    "    <li><a href=\"{0}{1}?pretty=true\">Metrics</a></li>%n" +
-                    "    <li><a href=\"{2}{3}\">Ping</a></li>%n" +
-                    "    <li><a href=\"{4}{5}\">Threads</a></li>%n" +
-                    "    <li><a href=\"{6}{7}?pretty=true\">Healthcheck</a></li>%n" +
-                    "    <li><a href=\"{8}{9}\">CPU Profile</a></li>%n" +
-                    "    <li><a href=\"{8}{9}?state=blocked\">CPU Contention</a></li>%n" +
+                    "%s" +
                     "  </ul>%n" +
                     "</body>%n" +
-                    "</html>"
-    );
+                    "</html>";
+    private static final String METRICS_LINK = "    <li><a href=\"{0}{1}?pretty=true\">Metrics</a></li>%n";
+    private static final String PING_LINK = "    <li><a href=\"{2}{3}\">Ping</a></li>%n" ;
+    private static final String THREADS_LINK = "    <li><a href=\"{4}{5}\">Threads</a></li>%n" ;
+    private static final String HEALTHCHECK_LINK = "    <li><a href=\"{6}{7}?pretty=true\">Healthcheck</a></li>%n" ;
+    private static final String CPU_PROFILE_LINK = "    <li><a href=\"{8}{9}\">CPU Profile</a></li>%n" +
+            "    <li><a href=\"{8}{9}?state=blocked\">CPU Contention</a></li>%n";
+
+
     private static final String CONTENT_TYPE = "text/html";
     private static final long serialVersionUID = -2850794040708785318L;
 
@@ -51,38 +59,74 @@ public class AdminServlet extends HttpServlet {
     private transient PingServlet pingServlet;
     private transient ThreadDumpServlet threadDumpServlet;
     private transient CpuProfileServlet cpuProfileServlet;
+    private transient boolean metricsEnabled;
     private transient String metricsUri;
+    private transient boolean pingEnabled;
     private transient String pingUri;
+    private transient boolean threadsEnabled;
     private transient String threadsUri;
+    private transient boolean healthcheckEnabled;
     private transient String healthcheckUri;
-    private transient String cpuprofileUri;
+    private transient boolean cpuProfileEnabled;
+    private transient String cpuProfileUri;
     private transient String serviceName;
+    private transient String pageContentTemplate;
 
     @Override
     public void init(ServletConfig config) throws ServletException {
         super.init(config);
 
-        this.healthCheckServlet = new HealthCheckServlet();
-        healthCheckServlet.init(config);
+        final ServletContext context = config.getServletContext();
+        final StringBuilder servletLinks = new StringBuilder();
 
+        this.metricsEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(METRICS_ENABLED_PARAM_KEY), "true"));
+        if (this.metricsEnabled) {
+            servletLinks.append(METRICS_LINK);
+        }
         this.metricsServlet = new MetricsServlet();
         metricsServlet.init(config);
 
+        this.pingEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(PING_ENABLED_PARAM_KEY), "true"));
+        if (this.pingEnabled) {
+            servletLinks.append(PING_LINK);
+        }
         this.pingServlet = new PingServlet();
         pingServlet.init(config);
 
+        this.threadsEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(THREADS_ENABLED_PARAM_KEY), "true"));
+        if (this.threadsEnabled) {
+            servletLinks.append(THREADS_LINK);
+        }
         this.threadDumpServlet = new ThreadDumpServlet();
         threadDumpServlet.init(config);
 
+        this.healthcheckEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(HEALTHCHECK_ENABLED_PARAM_KEY), "true"));
+        if (this.healthcheckEnabled) {
+            servletLinks.append(HEALTHCHECK_LINK);
+        }
+        this.healthCheckServlet = new HealthCheckServlet();
+        healthCheckServlet.init(config);
+
+        this.cpuProfileEnabled =
+                Boolean.parseBoolean(getParam(context.getInitParameter(CPU_PROFILE_ENABLED_PARAM_KEY), "true"));
+        if (this.cpuProfileEnabled) {
+            servletLinks.append(CPU_PROFILE_LINK);
+        }
         this.cpuProfileServlet = new CpuProfileServlet();
         cpuProfileServlet.init(config);
 
-        this.metricsUri = getParam(config.getInitParameter(METRICS_URI_PARAM_KEY), DEFAULT_METRICS_URI);
-        this.pingUri = getParam(config.getInitParameter(PING_URI_PARAM_KEY), DEFAULT_PING_URI);
-        this.threadsUri = getParam(config.getInitParameter(THREADS_URI_PARAM_KEY), DEFAULT_THREADS_URI);
-        this.healthcheckUri = getParam(config.getInitParameter(HEALTHCHECK_URI_PARAM_KEY), DEFAULT_HEALTHCHECK_URI);
-        this.cpuprofileUri = getParam(config.getInitParameter(CPU_PROFILE_URI_PARAM_KEY), DEFAULT_CPU_PROFILE_URI);
-        this.serviceName = getParam(config.getInitParameter(SERVICE_NAME_PARAM_KEY), null);
+        pageContentTemplate = String.format(BASE_TEMPLATE, String.format(servletLinks.toString()));
+
+        this.metricsUri = getParam(context.getInitParameter(METRICS_URI_PARAM_KEY), DEFAULT_METRICS_URI);
+        this.pingUri = getParam(context.getInitParameter(PING_URI_PARAM_KEY), DEFAULT_PING_URI);
+        this.threadsUri = getParam(context.getInitParameter(THREADS_URI_PARAM_KEY), DEFAULT_THREADS_URI);
+        this.healthcheckUri = getParam(context.getInitParameter(HEALTHCHECK_URI_PARAM_KEY), DEFAULT_HEALTHCHECK_URI);
+        this.cpuProfileUri = getParam(context.getInitParameter(CPU_PROFILE_URI_PARAM_KEY), DEFAULT_CPU_PROFILE_URI);
+        this.serviceName = getParam(context.getInitParameter(SERVICE_NAME_PARAM_KEY), null);
     }
 
     @Override
@@ -92,13 +136,10 @@ public class AdminServlet extends HttpServlet {
         resp.setStatus(HttpServletResponse.SC_OK);
         resp.setHeader("Cache-Control", "must-revalidate,no-cache,no-store");
         resp.setContentType(CONTENT_TYPE);
-        final PrintWriter writer = resp.getWriter();
-        try {
-            writer.println(MessageFormat.format(TEMPLATE, path, metricsUri, path, pingUri, path,
-                                                threadsUri, path, healthcheckUri, path, cpuprofileUri,
-                                                serviceName == null ? "" : " (" + serviceName + ")"));
-        } finally {
-            writer.close();
+        try (PrintWriter writer = resp.getWriter()) {
+            writer.println(MessageFormat.format(pageContentTemplate, path, metricsUri, path, pingUri, path,
+                    threadsUri, path, healthcheckUri, path, cpuProfileUri,
+                    serviceName == null ? "" : " (" + serviceName + ")"));
         }
     }
 
@@ -108,15 +149,35 @@ public class AdminServlet extends HttpServlet {
         if (uri == null || uri.equals("/")) {
             super.service(req, resp);
         } else if (uri.equals(healthcheckUri)) {
-            healthCheckServlet.service(req, resp);
+            if (healthcheckEnabled) {
+                healthCheckServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
         } else if (uri.startsWith(metricsUri)) {
-            metricsServlet.service(req, resp);
+            if (metricsEnabled) {
+                metricsServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
         } else if (uri.equals(pingUri)) {
-            pingServlet.service(req, resp);
+            if (pingEnabled) {
+                pingServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
         } else if (uri.equals(threadsUri)) {
-            threadDumpServlet.service(req, resp);
-        } else if (uri.equals(cpuprofileUri)) {
-            cpuProfileServlet.service(req, resp);
+            if (threadsEnabled) {
+                threadDumpServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
+        } else if (uri.equals(cpuProfileUri)) {
+            if (cpuProfileEnabled) {
+                cpuProfileServlet.service(req, resp);
+            } else {
+                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
         } else {
             resp.sendError(HttpServletResponse.SC_NOT_FOUND);
         }
diff --git a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/AdminServletContextListener.java b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/AdminServletContextListener.java
deleted file mode 100644
index 6104638..0000000
--- a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/AdminServletContextListener.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.codahale.metrics.servlets;
-
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.health.HealthCheckRegistry;
-
-import javax.servlet.ServletContext;
-import javax.servlet.ServletContextEvent;
-import javax.servlet.ServletContextListener;
-import java.util.concurrent.ExecutorService;
-
-/**
- * A listener implementation which injects a {@link MetricRegistry} instance, a
- * {@link HealthCheckRegistry} instance, and an optional {@link ExecutorService} instance  into the
- * servlet context as named attributes.
- *
- * @deprecated Use {@link MetricsServlet.ContextListener} and
- *             {@link HealthCheckServlet.ContextListener} instead.
- */
-@Deprecated
-public abstract class AdminServletContextListener implements ServletContextListener {
-    /**
-     * @return the {@link MetricRegistry} to inject into the servlet context.
-     */
-    protected abstract MetricRegistry getMetricRegistry();
-
-    /**
-     * @return the {@link HealthCheckRegistry} to inject into the servlet context.
-     */
-    protected abstract HealthCheckRegistry getHealthCheckRegistry();
-
-    /**
-     * @return the {@link ExecutorService} to inject into the servlet context, or {@code null} if
-     * the health checks should be run in the servlet worker thread.
-     */
-    protected ExecutorService getExecutorService() {
-        // don't use a thread pool by default
-        return null;
-    }
-
-    @Override
-    public void contextInitialized(ServletContextEvent servletContextEvent) {
-        final ServletContext context = servletContextEvent.getServletContext();
-        context.setAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY, getHealthCheckRegistry());
-        context.setAttribute(HealthCheckServlet.HEALTH_CHECK_EXECUTOR, getExecutorService());
-        context.setAttribute(MetricsServlet.METRICS_REGISTRY, getMetricRegistry());
-    }
-
-    @Override
-    public void contextDestroyed(ServletContextEvent servletContextEvent) {
-        // no-op...
-    }
-}
diff --git a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/CpuProfileServlet.java b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/CpuProfileServlet.java
index a49f9a6..88a9089 100644
--- a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/CpuProfileServlet.java
+++ b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/CpuProfileServlet.java
@@ -2,13 +2,14 @@ package com.codahale.metrics.servlets;
 
 import java.io.IOException;
 import java.io.OutputStream;
+import java.time.Duration;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.joda.time.Duration;
+
 import com.papertrail.profiler.CpuProfile;
 
 /**
@@ -38,6 +39,7 @@ public class CpuProfileServlet extends HttpServlet {
         if (req.getParameter("frequency") != null) {
             try {
                 frequency = Integer.parseInt(req.getParameter("frequency"));
+                frequency = Math.min(Math.max(frequency, 1), 1000);
             } catch (NumberFormatException e) {
                 frequency = 100;
             }
@@ -46,26 +48,22 @@ public class CpuProfileServlet extends HttpServlet {
         final Thread.State state;
         if ("blocked".equalsIgnoreCase(req.getParameter("state"))) {
             state = Thread.State.BLOCKED;
-        }
-        else {
+        } else {
             state = Thread.State.RUNNABLE;
         }
 
         resp.setStatus(HttpServletResponse.SC_OK);
         resp.setHeader(CACHE_CONTROL, NO_CACHE);
         resp.setContentType(CONTENT_TYPE);
-        final OutputStream output = resp.getOutputStream();
-        try {
+        try (OutputStream output = resp.getOutputStream()) {
             doProfile(output, duration, frequency, state);
-        } finally {
-            output.close();
         }
     }
 
     protected void doProfile(OutputStream out, int duration, int frequency, Thread.State state) throws IOException {
         if (lock.tryLock()) {
             try {
-                CpuProfile profile = CpuProfile.record(Duration.standardSeconds(duration),
+                CpuProfile profile = CpuProfile.record(Duration.ofSeconds(duration),
                         frequency, state);
                 if (profile == null) {
                     throw new RuntimeException("could not create CpuProfile");
diff --git a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/HealthCheckServlet.java b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/HealthCheckServlet.java
index c670158..3865a2d 100644
--- a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/HealthCheckServlet.java
+++ b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/HealthCheckServlet.java
@@ -1,12 +1,17 @@
 package com.codahale.metrics.servlets;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.ObjectWriter;
 import com.codahale.metrics.health.HealthCheck;
+import com.codahale.metrics.health.HealthCheckFilter;
 import com.codahale.metrics.health.HealthCheckRegistry;
 import com.codahale.metrics.json.HealthCheckModule;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
 
-import javax.servlet.*;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletContextEvent;
+import javax.servlet.ServletContextListener;
+import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -32,11 +37,29 @@ public class HealthCheckServlet extends HttpServlet {
             return null;
         }
 
+        /**
+         * @return the {@link HealthCheckFilter} that shall be used to filter health checks,
+         * or {@link HealthCheckFilter#ALL} if the default should be used.
+         */
+        protected HealthCheckFilter getHealthCheckFilter() {
+            return HealthCheckFilter.ALL;
+        }
+
+        /**
+         * @return the {@link ObjectMapper} that shall be used to render health checks,
+         * or {@code null} if the default object mapper should be used.
+         */
+        protected ObjectMapper getObjectMapper() {
+            // don't use an object mapper by default
+            return null;
+        }
+
         @Override
         public void contextInitialized(ServletContextEvent event) {
             final ServletContext context = event.getServletContext();
             context.setAttribute(HEALTH_CHECK_REGISTRY, getHealthCheckRegistry());
             context.setAttribute(HEALTH_CHECK_EXECUTOR, getExecutorService());
+            context.setAttribute(HEALTH_CHECK_MAPPER, getObjectMapper());
         }
 
         @Override
@@ -47,13 +70,19 @@ public class HealthCheckServlet extends HttpServlet {
 
     public static final String HEALTH_CHECK_REGISTRY = HealthCheckServlet.class.getCanonicalName() + ".registry";
     public static final String HEALTH_CHECK_EXECUTOR = HealthCheckServlet.class.getCanonicalName() + ".executor";
+    public static final String HEALTH_CHECK_FILTER = HealthCheckServlet.class.getCanonicalName() + ".healthCheckFilter";
+    public static final String HEALTH_CHECK_MAPPER = HealthCheckServlet.class.getCanonicalName() + ".mapper";
+    public static final String HEALTH_CHECK_HTTP_STATUS_INDICATOR = HealthCheckServlet.class.getCanonicalName() + ".httpStatusIndicator";
 
     private static final long serialVersionUID = -8432996484889177321L;
     private static final String CONTENT_TYPE = "application/json";
+    private static final String HTTP_STATUS_INDICATOR_PARAM = "httpStatusIndicator";
 
     private transient HealthCheckRegistry registry;
     private transient ExecutorService executorService;
+    private transient HealthCheckFilter filter;
     private transient ObjectMapper mapper;
+    private transient boolean httpStatusIndicator;
 
     public HealthCheckServlet() {
     }
@@ -61,13 +90,14 @@ public class HealthCheckServlet extends HttpServlet {
     public HealthCheckServlet(HealthCheckRegistry registry) {
         this.registry = registry;
     }
-    
+
     @Override
     public void init(ServletConfig config) throws ServletException {
         super.init(config);
 
+        final ServletContext context = config.getServletContext();
         if (null == registry) {
-            final Object registryAttr = config.getServletContext().getAttribute(HEALTH_CHECK_REGISTRY);
+            final Object registryAttr = context.getAttribute(HEALTH_CHECK_REGISTRY);
             if (registryAttr instanceof HealthCheckRegistry) {
                 this.registry = (HealthCheckRegistry) registryAttr;
             } else {
@@ -75,12 +105,33 @@ public class HealthCheckServlet extends HttpServlet {
             }
         }
 
-        final Object executorAttr = config.getServletContext().getAttribute(HEALTH_CHECK_EXECUTOR);
+        final Object executorAttr = context.getAttribute(HEALTH_CHECK_EXECUTOR);
         if (executorAttr instanceof ExecutorService) {
             this.executorService = (ExecutorService) executorAttr;
         }
 
-        this.mapper = new ObjectMapper().registerModule(new HealthCheckModule());
+        final Object filterAttr = context.getAttribute(HEALTH_CHECK_FILTER);
+        if (filterAttr instanceof HealthCheckFilter) {
+            filter = (HealthCheckFilter) filterAttr;
+        }
+        if (filter == null) {
+            filter = HealthCheckFilter.ALL;
+        }
+
+        final Object mapperAttr = context.getAttribute(HEALTH_CHECK_MAPPER);
+        if (mapperAttr instanceof ObjectMapper) {
+            this.mapper = (ObjectMapper) mapperAttr;
+        } else {
+            this.mapper = new ObjectMapper();
+        }
+        this.mapper.registerModule(new HealthCheckModule());
+
+        final Object httpStatusIndicatorAttr = context.getAttribute(HEALTH_CHECK_HTTP_STATUS_INDICATOR);
+        if (httpStatusIndicatorAttr instanceof Boolean) {
+            this.httpStatusIndicator = (Boolean) httpStatusIndicatorAttr;
+        } else {
+            this.httpStatusIndicator = true;
+        }
     }
 
     @Override
@@ -98,18 +149,18 @@ public class HealthCheckServlet extends HttpServlet {
         if (results.isEmpty()) {
             resp.setStatus(HttpServletResponse.SC_NOT_IMPLEMENTED);
         } else {
-            if (isAllHealthy(results)) {
+            final String reqParameter = req.getParameter(HTTP_STATUS_INDICATOR_PARAM);
+            final boolean httpStatusIndicatorParam = Boolean.parseBoolean(reqParameter);
+            final boolean useHttpStatusForHealthCheck = reqParameter == null ? httpStatusIndicator : httpStatusIndicatorParam;
+            if (!useHttpStatusForHealthCheck || isAllHealthy(results)) {
                 resp.setStatus(HttpServletResponse.SC_OK);
             } else {
                 resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
             }
         }
 
-        final OutputStream output = resp.getOutputStream();
-        try {
+        try (OutputStream output = resp.getOutputStream()) {
             getWriter(req).writeValue(output, results);
-        } finally {
-            output.close();
         }
     }
 
@@ -123,9 +174,9 @@ public class HealthCheckServlet extends HttpServlet {
 
     private SortedMap<String, HealthCheck.Result> runHealthChecks() {
         if (executorService == null) {
-            return registry.runHealthChecks();
+            return registry.runHealthChecks(filter);
         }
-        return registry.runHealthChecks(executorService);
+        return registry.runHealthChecks(executorService, filter);
     }
 
     private static boolean isAllHealthy(Map<String, HealthCheck.Result> results) {
@@ -136,4 +187,9 @@ public class HealthCheckServlet extends HttpServlet {
         }
         return true;
     }
+
+    // visible for testing
+    ObjectMapper getMapper() {
+        return mapper;
+    }
 }
diff --git a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/MetricsServlet.java b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/MetricsServlet.java
index 31232cf..0bd1297 100644
--- a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/MetricsServlet.java
+++ b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/MetricsServlet.java
@@ -114,10 +114,10 @@ public class MetricsServlet extends HttpServlet {
     private static final long serialVersionUID = 1049773947734939602L;
     private static final String CONTENT_TYPE = "application/json";
 
-    private String allowedOrigin;
-    private String jsonpParamName;
-    private transient MetricRegistry registry;
-    private transient ObjectMapper mapper;
+    protected String allowedOrigin;
+    protected String jsonpParamName;
+    protected transient MetricRegistry registry;
+    protected transient ObjectMapper mapper;
 
     public MetricsServlet() {
     }
@@ -139,23 +139,27 @@ public class MetricsServlet extends HttpServlet {
                 throw new ServletException("Couldn't find a MetricRegistry instance.");
             }
         }
+        this.allowedOrigin = context.getInitParameter(ALLOWED_ORIGIN);
+        this.jsonpParamName = context.getInitParameter(CALLBACK_PARAM);
 
+        setupMetricsModule(context);
+    }
+
+    protected void setupMetricsModule(ServletContext context) {
         final TimeUnit rateUnit = parseTimeUnit(context.getInitParameter(RATE_UNIT),
-                                                TimeUnit.SECONDS);
+                TimeUnit.SECONDS);
         final TimeUnit durationUnit = parseTimeUnit(context.getInitParameter(DURATION_UNIT),
-                                                    TimeUnit.SECONDS);
+                TimeUnit.SECONDS);
         final boolean showSamples = Boolean.parseBoolean(context.getInitParameter(SHOW_SAMPLES));
         MetricFilter filter = (MetricFilter) context.getAttribute(METRIC_FILTER);
         if (filter == null) {
-          filter = MetricFilter.ALL;
+            filter = MetricFilter.ALL;
         }
-        this.mapper = new ObjectMapper().registerModule(new MetricsModule(rateUnit,
-                                                                          durationUnit,
-                                                                          showSamples,
-                                                                          filter));
 
-        this.allowedOrigin = context.getInitParameter(ALLOWED_ORIGIN);
-        this.jsonpParamName = context.getInitParameter(CALLBACK_PARAM);
+        this.mapper = new ObjectMapper().registerModule(new MetricsModule(rateUnit,
+                durationUnit,
+                showSamples,
+                filter));
     }
 
     @Override
@@ -168,19 +172,16 @@ public class MetricsServlet extends HttpServlet {
         resp.setHeader("Cache-Control", "must-revalidate,no-cache,no-store");
         resp.setStatus(HttpServletResponse.SC_OK);
 
-        final OutputStream output = resp.getOutputStream();
-        try {
+        try (OutputStream output = resp.getOutputStream()) {
             if (jsonpParamName != null && req.getParameter(jsonpParamName) != null) {
                 getWriter(req).writeValue(output, new JSONPObject(req.getParameter(jsonpParamName), registry));
             } else {
                 getWriter(req).writeValue(output, registry);
             }
-        } finally {
-            output.close();
         }
     }
 
-    private ObjectWriter getWriter(HttpServletRequest request) {
+    protected ObjectWriter getWriter(HttpServletRequest request) {
         final boolean prettyPrint = Boolean.parseBoolean(request.getParameter("pretty"));
         if (prettyPrint) {
             return mapper.writerWithDefaultPrettyPrinter();
@@ -188,7 +189,7 @@ public class MetricsServlet extends HttpServlet {
         return mapper.writer();
     }
 
-    private TimeUnit parseTimeUnit(String value, TimeUnit defaultValue) {
+    protected TimeUnit parseTimeUnit(String value, TimeUnit defaultValue) {
         try {
             return TimeUnit.valueOf(String.valueOf(value).toUpperCase(Locale.US));
         } catch (IllegalArgumentException e) {
diff --git a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/PingServlet.java b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/PingServlet.java
index 12387ec..6eac5d0 100644
--- a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/PingServlet.java
+++ b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/PingServlet.java
@@ -23,11 +23,8 @@ public class PingServlet extends HttpServlet {
         resp.setStatus(HttpServletResponse.SC_OK);
         resp.setHeader(CACHE_CONTROL, NO_CACHE);
         resp.setContentType(CONTENT_TYPE);
-        final PrintWriter writer = resp.getWriter();
-        try {
+        try (PrintWriter writer = resp.getWriter()) {
             writer.println(CONTENT);
-        } finally {
-            writer.close();
         }
     }
 }
diff --git a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/ThreadDumpServlet.java b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/ThreadDumpServlet.java
index b1d3e42..80615d1 100644
--- a/metrics-servlets/src/main/java/com/codahale/metrics/servlets/ThreadDumpServlet.java
+++ b/metrics-servlets/src/main/java/com/codahale/metrics/servlets/ThreadDumpServlet.java
@@ -33,7 +33,10 @@ public class ThreadDumpServlet extends HttpServlet {
 
     @Override
     protected void doGet(HttpServletRequest req,
-            HttpServletResponse resp) throws ServletException, IOException {
+                         HttpServletResponse resp) throws ServletException, IOException {
+        final boolean includeMonitors = getParam(req.getParameter("monitors"), true);
+        final boolean includeSynchronizers = getParam(req.getParameter("synchronizers"), true);
+
         resp.setStatus(HttpServletResponse.SC_OK);
         resp.setContentType(CONTENT_TYPE);
         resp.setHeader("Cache-Control", "must-revalidate,no-cache,no-store");
@@ -41,11 +44,12 @@ public class ThreadDumpServlet extends HttpServlet {
             resp.getWriter().println("Sorry your runtime environment does not allow to dump threads.");
             return;
         }
-        final OutputStream output = resp.getOutputStream();
-        try {
-            threadDump.dump(output);
-        } finally {
-            output.close();
+        try (OutputStream output = resp.getOutputStream()) {
+            threadDump.dump(includeMonitors, includeSynchronizers, output);
         }
     }
+
+    private static Boolean getParam(String initParam, boolean defaultValue) {
+        return initParam == null ? defaultValue : Boolean.parseBoolean(initParam);
+    }
 }
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletContextListenerTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletContextListenerTest.java
deleted file mode 100644
index c23a70e..0000000
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletContextListenerTest.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.codahale.metrics.servlets;
-
-import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.health.HealthCheckRegistry;
-import org.junit.Before;
-import org.junit.Test;
-
-import javax.servlet.ServletContext;
-import javax.servlet.ServletContextEvent;
-import java.util.concurrent.ExecutorService;
-
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-
-@SuppressWarnings("deprecation")
-public class AdminServletContextListenerTest {
-    private final MetricRegistry metricRegistry = mock(MetricRegistry.class);
-    private final HealthCheckRegistry healthCheckRegistry = mock(HealthCheckRegistry.class);
-    private final ExecutorService executorService = mock(ExecutorService.class);
-    private final AdminServletContextListener listener = new AdminServletContextListener() {
-        @Override
-        protected MetricRegistry getMetricRegistry() {
-            return metricRegistry;
-        }
-
-        @Override
-        protected HealthCheckRegistry getHealthCheckRegistry() {
-            return healthCheckRegistry;
-        }
-
-        @Override
-        protected ExecutorService getExecutorService() {
-            return executorService;
-        }
-    };
-
-    private final ServletContext context = mock(ServletContext.class);
-    private final ServletContextEvent event = mock(ServletContextEvent.class);
-
-    @Before
-    public void setUp() throws Exception {
-        when(event.getServletContext()).thenReturn(context);
-    }
-
-    @Test
-    public void injectsTheMetricRegistry() throws Exception {
-        listener.contextInitialized(event);
-
-        verify(context).setAttribute("com.codahale.metrics.servlets.MetricsServlet.registry", metricRegistry);
-    }
-
-    @Test
-    public void injectsTheHealthCheckRegistry() throws Exception {
-        listener.contextInitialized(event);
-
-        verify(context).setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.registry", healthCheckRegistry);
-    }
-
-    @Test
-    public void injectsTheHealthCheckExecutor() throws Exception {
-        listener.contextInitialized(event);
-
-        verify(context).setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.executor", executorService);
-    }
-}
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletExclusionTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletExclusionTest.java
new file mode 100755
index 0000000..5fafab8
--- /dev/null
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletExclusionTest.java
@@ -0,0 +1,60 @@
+package com.codahale.metrics.servlets;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.health.HealthCheckRegistry;
+import static org.assertj.core.api.Assertions.assertThat;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AdminServletExclusionTest extends AbstractServletTest {
+    private final MetricRegistry registry = new MetricRegistry();
+    private final HealthCheckRegistry healthCheckRegistry = new HealthCheckRegistry();
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.setContextPath("/context");
+
+        tester.setAttribute("com.codahale.metrics.servlets.MetricsServlet.registry", registry);
+        tester.setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.registry", healthCheckRegistry);
+        tester.setInitParameter("threads-enabled", "false");
+        tester.setInitParameter("cpu-profile-enabled", "false");
+        tester.addServlet(AdminServlet.class, "/admin");
+    }
+
+    @Before
+    public void setUp() {
+        request.setMethod("GET");
+        request.setURI("/context/admin");
+        request.setVersion("HTTP/1.0");
+    }
+
+    @Test
+    public void returnsA200() throws Exception {
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.getContent())
+                .isEqualTo(String.format(
+                        "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"%n" +
+                                "        \"http://www.w3.org/TR/html4/loose.dtd\">%n" +
+                                "<html>%n" +
+                                "<head>%n" +
+                                "  <title>Metrics</title>%n" +
+                                "</head>%n" +
+                                "<body>%n" +
+                                "  <h1>Operational Menu</h1>%n" +
+                                "  <ul>%n" +
+                                "    <li><a href=\"/context/admin/metrics?pretty=true\">Metrics</a></li>%n" +
+                                "    <li><a href=\"/context/admin/ping\">Ping</a></li>%n" +
+                                "    <li><a href=\"/context/admin/healthcheck?pretty=true\">Healthcheck</a></li>%n" +
+                                "  </ul>%n" +
+                                "</body>%n" +
+                                "</html>%n"
+                ));
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("text/html;charset=UTF-8");
+    }
+}
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletTest.java
index f6c58f2..4d2e6f8 100755
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletTest.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletTest.java
@@ -23,7 +23,7 @@ public class AdminServletTest extends AbstractServletTest {
     }
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         request.setMethod("GET");
         request.setURI("/context/admin");
         request.setVersion("HTTP/1.0");
@@ -57,6 +57,6 @@ public class AdminServletTest extends AbstractServletTest {
                                 "</html>%n"
                 ));
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
-                .isEqualTo("text/html; charset=ISO-8859-1");
+                .isEqualTo("text/html;charset=UTF-8");
     }
 }
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletUriTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletUriTest.java
new file mode 100755
index 0000000..b97530e
--- /dev/null
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/AdminServletUriTest.java
@@ -0,0 +1,66 @@
+package com.codahale.metrics.servlets;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.health.HealthCheckRegistry;
+import static org.assertj.core.api.Assertions.assertThat;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.servlet.ServletTester;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AdminServletUriTest extends AbstractServletTest {
+    private final MetricRegistry registry = new MetricRegistry();
+    private final HealthCheckRegistry healthCheckRegistry = new HealthCheckRegistry();
+
+    @Override
+    protected void setUp(ServletTester tester) {
+        tester.setContextPath("/context");
+
+        tester.setAttribute("com.codahale.metrics.servlets.MetricsServlet.registry", registry);
+        tester.setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.registry", healthCheckRegistry);
+        tester.setInitParameter("metrics-uri", "/metrics-test");
+        tester.setInitParameter("ping-uri", "/ping-test");
+        tester.setInitParameter("threads-uri", "/threads-test");
+        tester.setInitParameter("healthcheck-uri", "/healthcheck-test");
+        tester.setInitParameter("cpu-profile-uri", "/pprof-test");
+        tester.addServlet(AdminServlet.class, "/admin");
+    }
+
+    @Before
+    public void setUp() {
+        request.setMethod("GET");
+        request.setURI("/context/admin");
+        request.setVersion("HTTP/1.0");
+    }
+
+    @Test
+    public void returnsA200() throws Exception {
+        processRequest();
+
+        assertThat(response.getStatus())
+                .isEqualTo(200);
+        assertThat(response.getContent())
+                .isEqualTo(String.format(
+                        "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\"%n" +
+                                "        \"http://www.w3.org/TR/html4/loose.dtd\">%n" +
+                                "<html>%n" +
+                                "<head>%n" +
+                                "  <title>Metrics</title>%n" +
+                                "</head>%n" +
+                                "<body>%n" +
+                                "  <h1>Operational Menu</h1>%n" +
+                                "  <ul>%n" +
+                                "    <li><a href=\"/context/admin/metrics-test?pretty=true\">Metrics</a></li>%n" +
+                                "    <li><a href=\"/context/admin/ping-test\">Ping</a></li>%n" +
+                                "    <li><a href=\"/context/admin/threads-test\">Threads</a></li>%n" +
+                                "    <li><a href=\"/context/admin/healthcheck-test?pretty=true\">Healthcheck</a></li>%n" +
+                                "    <li><a href=\"/context/admin/pprof-test\">CPU Profile</a></li>%n" +
+                                "    <li><a href=\"/context/admin/pprof-test?state=blocked\">CPU Contention</a></li>%n" +
+                                "  </ul>%n" +
+                                "</body>%n" +
+                                "</html>%n"
+                ));
+        assertThat(response.get(HttpHeader.CONTENT_TYPE))
+                .isEqualTo("text/html;charset=UTF-8");
+    }
+}
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/CpuProfileServletTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/CpuProfileServletTest.java
index 280672c..6e7de41 100644
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/CpuProfileServletTest.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/CpuProfileServletTest.java
@@ -1,6 +1,7 @@
 package com.codahale.metrics.servlets;
 
 import static org.assertj.core.api.Assertions.assertThat;
+
 import org.eclipse.jetty.http.HttpHeader;
 import org.eclipse.jetty.servlet.ServletTester;
 import org.junit.Before;
@@ -23,19 +24,19 @@ public class CpuProfileServletTest extends AbstractServletTest {
     }
 
     @Test
-    public void returns200OK() throws Exception {
+    public void returns200OK() {
         assertThat(response.getStatus())
                 .isEqualTo(200);
     }
 
     @Test
-    public void returnsPprofRaw() throws Exception {
+    public void returnsPprofRaw() {
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
                 .isEqualTo("pprof/raw");
     }
 
     @Test
-    public void returnsUncacheable() throws Exception {
+    public void returnsUncacheable() {
         assertThat(response.get(HttpHeader.CACHE_CONTROL))
                 .isEqualTo("must-revalidate,no-cache,no-store");
 
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/HealthCheckServletTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/HealthCheckServletTest.java
index 6822879..b3ccc9b 100644
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/HealthCheckServletTest.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/HealthCheckServletTest.java
@@ -1,44 +1,77 @@
 package com.codahale.metrics.servlets;
 
+import com.codahale.metrics.Clock;
 import com.codahale.metrics.health.HealthCheck;
+import com.codahale.metrics.health.HealthCheckFilter;
 import com.codahale.metrics.health.HealthCheckRegistry;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.eclipse.jetty.http.HttpHeader;
 import org.eclipse.jetty.servlet.ServletTester;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-import static org.mockito.Mockito.*;
-
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 public class HealthCheckServletTest extends AbstractServletTest {
+
+    private static final ZonedDateTime FIXED_TIME = ZonedDateTime.now();
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
+
+    private static final String EXPECTED_TIMESTAMP = DATE_TIME_FORMATTER.format(FIXED_TIME);
+
+    private static final Clock FIXED_CLOCK = new Clock() {
+        @Override
+        public long getTick() {
+            return 0L;
+        }
+
+        @Override
+        public long getTime() {
+            return FIXED_TIME.toInstant().toEpochMilli();
+        }
+    };
+
     private final HealthCheckRegistry registry = new HealthCheckRegistry();
     private final ExecutorService threadPool = Executors.newCachedThreadPool();
+    private final ObjectMapper mapper = new ObjectMapper();
 
     @Override
     protected void setUp(ServletTester tester) {
         tester.addServlet(HealthCheckServlet.class, "/healthchecks");
         tester.setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.registry", registry);
         tester.setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.executor", threadPool);
+        tester.setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.mapper", mapper);
+        tester.setAttribute("com.codahale.metrics.servlets.HealthCheckServlet.healthCheckFilter",
+                (HealthCheckFilter) (name, healthCheck) -> !"filtered".equals(name));
     }
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         request.setMethod("GET");
         request.setURI("/healthchecks");
         request.setVersion("HTTP/1.0");
     }
 
     @After
-    public void tearDown() throws Exception {
+    public void tearDown() {
         threadPool.shutdown();
     }
 
@@ -46,83 +79,103 @@ public class HealthCheckServletTest extends AbstractServletTest {
     public void returns501IfNoHealthChecksAreRegistered() throws Exception {
         processRequest();
 
-        assertThat(response.getStatus())
-                .isEqualTo(501);
-        assertThat(response.getContent())
-                .isEqualTo("{}");
-        assertThat(response.get(HttpHeader.CONTENT_TYPE))
-                .isEqualTo("application/json");
+        assertThat(response.getStatus()).isEqualTo(501);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent()).isEqualTo("{}");
     }
 
     @Test
     public void returnsA200IfAllHealthChecksAreHealthy() throws Exception {
-        registry.register("fun", new HealthCheck() {
-            @Override
-            protected Result check() throws Exception {
-                return Result.healthy("whee");
-            }
-        });
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
 
         processRequest();
 
-        assertThat(response.getStatus())
-                .isEqualTo(200);
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
         assertThat(response.getContent())
-                .isEqualTo("{\"fun\":{\"healthy\":true,\"message\":\"whee\"}}");
-        assertThat(response.get(HttpHeader.CONTENT_TYPE))
-                .isEqualTo("application/json");
+                .isEqualTo("{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" +
+                        EXPECTED_TIMESTAMP +
+                        "\"}}");
     }
 
     @Test
-    public void returnsA500IfAnyHealthChecksAreUnhealthy() throws Exception {
-        registry.register("fun", new HealthCheck() {
-            @Override
-            protected Result check() throws Exception {
-                return Result.healthy("whee");
-            }
-        });
-
-        registry.register("notFun", new HealthCheck() {
-            @Override
-            protected Result check() throws Exception {
-                return Result.unhealthy("whee");
-            }
-        });
+    public void returnsASubsetOfHealthChecksIfFiltered() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
+        registry.register("filtered", new TestHealthCheck(() -> unhealthyResultWithMessage("whee")));
 
         processRequest();
 
-        assertThat(response.getStatus())
-                .isEqualTo(500);
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
         assertThat(response.getContent())
-                .isEqualTo("{\"fun\":{\"healthy\":true,\"message\":\"whee\"},\"notFun\":{\"healthy\":false,\"message\":\"whee\"}}");
-        assertThat(response.get(HttpHeader.CONTENT_TYPE))
-                .isEqualTo("application/json");
+                .isEqualTo("{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" +
+                        EXPECTED_TIMESTAMP +
+                        "\"}}");
+    }
+
+    @Test
+    public void returnsA500IfAnyHealthChecksAreUnhealthy() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
+        registry.register("notFun", new TestHealthCheck(() -> unhealthyResultWithMessage("whee")));
+
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(500);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent()).contains(
+                        "{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}",
+                        ",\"notFun\":{\"healthy\":false,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}}");
+    }
+
+    @Test
+    public void returnsA200IfAnyHealthChecksAreUnhealthyAndHttpStatusIndicatorIsDisabled() throws Exception {
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("whee")));
+        registry.register("notFun", new TestHealthCheck(() -> unhealthyResultWithMessage("whee")));
+        request.setURI("/healthchecks?httpStatusIndicator=false");
+
+        processRequest();
+
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
+        assertThat(response.getContent()).contains(
+                "{\"fun\":{\"healthy\":true,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}",
+                ",\"notFun\":{\"healthy\":false,\"message\":\"whee\",\"duration\":0,\"timestamp\":\"" + EXPECTED_TIMESTAMP + "\"}}");
     }
 
     @Test
     public void optionallyPrettyPrintsTheJson() throws Exception {
-        registry.register("fun", new HealthCheck() {
-            @Override
-            protected Result check() throws Exception {
-                return Result.healthy("whee");
-            }
-        });
+        registry.register("fun", new TestHealthCheck(() -> healthyResultWithMessage("foo bar 123")));
 
         request.setURI("/healthchecks?pretty=true");
 
         processRequest();
 
-        assertThat(response.getStatus())
-                .isEqualTo(200);
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThat(response.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json");
         assertThat(response.getContent())
                 .isEqualTo(String.format("{%n" +
-                                                 "  \"fun\" : {%n" +
-                                                 "    \"healthy\" : true,%n" +
-                                                 "    \"message\" : \"whee\"%n" +
-                                                 "  }%n" +
-                                                 "}"));
-        assertThat(response.get(HttpHeader.CONTENT_TYPE))
-                .isEqualTo("application/json");
+                        "  \"fun\" : {%n" +
+                        "    \"healthy\" : true,%n" +
+                        "    \"message\" : \"foo bar 123\",%n" +
+                        "    \"duration\" : 0,%n" +
+                        "    \"timestamp\" : \"" + EXPECTED_TIMESTAMP + "\"" +
+                        "%n  }%n}"));
+    }
+
+    private static HealthCheck.Result healthyResultWithMessage(String message) {
+        return HealthCheck.Result.builder()
+                .healthy()
+                .withMessage(message)
+                .usingClock(FIXED_CLOCK)
+                .build();
+    }
+
+    private static HealthCheck.Result unhealthyResultWithMessage(String message) {
+        return HealthCheck.Result.builder()
+                .unhealthy()
+                .withMessage(message)
+                .usingClock(FIXED_CLOCK)
+                .build();
     }
 
     @Test
@@ -131,12 +184,12 @@ public class HealthCheckServletTest extends AbstractServletTest {
         final ServletContext servletContext = mock(ServletContext.class);
         final ServletConfig servletConfig = mock(ServletConfig.class);
         when(servletConfig.getServletContext()).thenReturn(servletContext);
- 
+
         final HealthCheckServlet healthCheckServlet = new HealthCheckServlet(healthCheckRegistry);
         healthCheckServlet.init(servletConfig);
- 
+
         verify(servletConfig, times(1)).getServletContext();
-        verify(servletContext, never()).getAttribute(eq(HealthCheckServlet.HEALTH_CHECK_REGISTRY));
+        verify(servletContext, never()).getAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY);
     }
 
     @Test
@@ -145,14 +198,14 @@ public class HealthCheckServletTest extends AbstractServletTest {
         final ServletContext servletContext = mock(ServletContext.class);
         final ServletConfig servletConfig = mock(ServletConfig.class);
         when(servletConfig.getServletContext()).thenReturn(servletContext);
-        when(servletContext.getAttribute(eq(HealthCheckServlet.HEALTH_CHECK_REGISTRY)))
-       	    .thenReturn(healthCheckRegistry);
- 
+        when(servletContext.getAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY))
+                .thenReturn(healthCheckRegistry);
+
         final HealthCheckServlet healthCheckServlet = new HealthCheckServlet(null);
         healthCheckServlet.init(servletConfig);
- 
-        verify(servletConfig, times(2)).getServletContext();
-        verify(servletContext, times(1)).getAttribute(eq(HealthCheckServlet.HEALTH_CHECK_REGISTRY));
+
+        verify(servletConfig, times(1)).getServletContext();
+        verify(servletContext, times(1)).getAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY);
     }
 
     @Test(expected = ServletException.class)
@@ -160,10 +213,44 @@ public class HealthCheckServletTest extends AbstractServletTest {
         final ServletContext servletContext = mock(ServletContext.class);
         final ServletConfig servletConfig = mock(ServletConfig.class);
         when(servletConfig.getServletContext()).thenReturn(servletContext);
-        when(servletContext.getAttribute(eq(HealthCheckServlet.HEALTH_CHECK_REGISTRY)))
-            .thenReturn("IRELLEVANT_STRING");
- 
+        when(servletContext.getAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY))
+                .thenReturn("IRELLEVANT_STRING");
+
+        final HealthCheckServlet healthCheckServlet = new HealthCheckServlet(null);
+        healthCheckServlet.init(servletConfig);
+    }
+
+    @Test
+    public void constructorWithObjectMapperAsArgumentUsesServletConfigWhenNullButWrongTypeInContext() throws Exception {
+        final ServletContext servletContext = mock(ServletContext.class);
+        final ServletConfig servletConfig = mock(ServletConfig.class);
+        when(servletConfig.getServletContext()).thenReturn(servletContext);
+        when(servletContext.getAttribute(HealthCheckServlet.HEALTH_CHECK_REGISTRY)).thenReturn(registry);
+        when(servletContext.getAttribute(HealthCheckServlet.HEALTH_CHECK_MAPPER)).thenReturn("IRELLEVANT_STRING");
+
         final HealthCheckServlet healthCheckServlet = new HealthCheckServlet(null);
         healthCheckServlet.init(servletConfig);
+
+        assertThat(healthCheckServlet.getMapper())
+                .isNotNull()
+                .isInstanceOf(ObjectMapper.class);
+    }
+
+    static class TestHealthCheck extends HealthCheck {
+        private final Callable<Result> check;
+
+        public TestHealthCheck(Callable<Result> check) {
+            this.check = check;
+        }
+
+        @Override
+        protected Result check() throws Exception {
+            return check.call();
+        }
+
+        @Override
+        protected Clock clock() {
+            return FIXED_CLOCK;
+        }
     }
 }
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletContextListenerTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletContextListenerTest.java
index df27344..0bad5f4 100644
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletContextListenerTest.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletContextListenerTest.java
@@ -1,10 +1,14 @@
 package com.codahale.metrics.servlets;
 
-import com.codahale.metrics.*;
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
 import org.eclipse.jetty.http.HttpHeader;
 import org.eclipse.jetty.servlet.ServletTester;
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Test;
 
 import java.util.concurrent.TimeUnit;
@@ -22,7 +26,7 @@ public class MetricsServletContextListenerTest extends AbstractServletTest {
     protected void setUp(ServletTester tester) {
         tester.setAttribute("com.codahale.metrics.servlets.MetricsServlet.registry", registry);
         tester.addServlet(MetricsServlet.class, "/metrics");
-        tester.getContext().addEventListener(new MetricsServlet.ContextListener(){
+        tester.getContext().addEventListener(new MetricsServlet.ContextListener() {
             @Override
             protected MetricRegistry getMetricRegistry() {
                 return registry;
@@ -46,15 +50,12 @@ public class MetricsServletContextListenerTest extends AbstractServletTest {
     }
 
     @Before
-    public void setUp() throws Exception {
-        when(clock.getTick()).thenReturn(100L, 200L, 300L, 400L);
+    public void setUp() {
+        // provide ticks for the setup (calls getTick 6 times). The serialization in the tests themselves
+        // will call getTick again several times and always get the same value (the last specified here)
+        when(clock.getTick()).thenReturn(100L, 100L, 200L, 300L, 300L, 400L);
 
-        registry.register("g1", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return 100L;
-            }
-        });
+        registry.register("g1", (Gauge<Long>) () -> 100L);
         registry.counter("c").inc();
         registry.histogram("h").update(1);
         registry.register("m", new Meter(clock)).mark();
@@ -76,7 +77,7 @@ public class MetricsServletContextListenerTest extends AbstractServletTest {
                 .isEqualTo(allowedOrigin);
         assertThat(response.getContent())
                 .isEqualTo("{" +
-                        "\"version\":\"3.1.3\"," +
+                        "\"version\":\"4.0.0\"," +
                         "\"gauges\":{" +
                         "\"g1\":{\"value\":100}" +
                         "}," +
@@ -106,7 +107,7 @@ public class MetricsServletContextListenerTest extends AbstractServletTest {
                 .isEqualTo(allowedOrigin);
         assertThat(response.getContent())
                 .isEqualTo(String.format("{%n" +
-                        "  \"version\" : \"3.1.3\",%n" +
+                        "  \"version\" : \"4.0.0\",%n" +
                         "  \"gauges\" : {%n" +
                         "    \"g1\" : {%n" +
                         "      \"value\" : 100%n" +
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletTest.java
index c2036df..73bb160 100644
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletTest.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/MetricsServletTest.java
@@ -1,20 +1,23 @@
 package com.codahale.metrics.servlets;
 
-import com.codahale.metrics.*;
-
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
 import org.eclipse.jetty.http.HttpHeader;
 import org.eclipse.jetty.servlet.ServletTester;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.concurrent.TimeUnit;
-
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
+import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
@@ -35,15 +38,12 @@ public class MetricsServletTest extends AbstractServletTest {
     }
 
     @Before
-    public void setUp() throws Exception {
-        when(clock.getTick()).thenReturn(100L, 200L, 300L, 400L);
+    public void setUp() {
+        // provide ticks for the setup (calls getTick 6 times). The serialization in the tests themselves
+        // will call getTick again several times and always get the same value (the last specified here)
+        when(clock.getTick()).thenReturn(100L, 100L, 200L, 300L, 300L, 400L);
 
-        registry.register("g1", new Gauge<Long>() {
-            @Override
-            public Long getValue() {
-                return 100L;
-            }
-        });
+        registry.register("g1", (Gauge<Long>) () -> 100L);
         registry.counter("c").inc();
         registry.histogram("h").update(1);
         registry.register("m", new Meter(clock)).mark();
@@ -65,28 +65,28 @@ public class MetricsServletTest extends AbstractServletTest {
                 .isEqualTo("*");
         assertThat(response.getContent())
                 .isEqualTo("{" +
-                                   "\"version\":\"3.1.3\"," +
-                                   "\"gauges\":{" +
-                                       "\"g1\":{\"value\":100}" +
-                                   "}," +
-                                   "\"counters\":{" +
-                                       "\"c\":{\"count\":1}" +
-                                   "}," +
-                                   "\"histograms\":{" +
-                                       "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
-                                   "}," +
-                                   "\"meters\":{" +
-                                       "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
-                                   "}" +
-                               "}");
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{" +
+                        "\"g1\":{\"value\":100}" +
+                        "}," +
+                        "\"counters\":{" +
+                        "\"c\":{\"count\":1}" +
+                        "}," +
+                        "\"histograms\":{" +
+                        "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
+                        "}," +
+                        "\"meters\":{" +
+                        "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
+                        "}" +
+                        "}");
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
                 .isEqualTo("application/json");
     }
 
     @Test
     public void returnsJsonWhenJsonpInitParamNotSet() throws Exception {
-    	String callbackParamName = "callbackParam";
-    	String callbackParamVal = "callbackParamVal";
+        String callbackParamName = "callbackParam";
+        String callbackParamVal = "callbackParamVal";
         request.setURI("/metrics?" + callbackParamName + "=" + callbackParamVal);
         processRequest();
 
@@ -96,28 +96,28 @@ public class MetricsServletTest extends AbstractServletTest {
                 .isEqualTo("*");
         assertThat(response.getContent())
                 .isEqualTo("{" +
-                                   "\"version\":\"3.1.3\"," +
-                                   "\"gauges\":{" +
-                                       "\"g1\":{\"value\":100}" +
-                                   "}," +
-                                   "\"counters\":{" +
-                                       "\"c\":{\"count\":1}" +
-                                   "}," +
-                                   "\"histograms\":{" +
-                                       "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
-                                   "}," +
-                                   "\"meters\":{" +
-                                       "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
-                                   "}" +
-                               "}");
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{" +
+                        "\"g1\":{\"value\":100}" +
+                        "}," +
+                        "\"counters\":{" +
+                        "\"c\":{\"count\":1}" +
+                        "}," +
+                        "\"histograms\":{" +
+                        "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
+                        "}," +
+                        "\"meters\":{" +
+                        "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
+                        "}" +
+                        "}");
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
                 .isEqualTo("application/json");
     }
 
     @Test
     public void returnsJsonpWhenInitParamSet() throws Exception {
-    	String callbackParamName = "callbackParam";
-    	String callbackParamVal = "callbackParamVal";
+        String callbackParamName = "callbackParam";
+        String callbackParamVal = "callbackParamVal";
         request.setURI("/metrics?" + callbackParamName + "=" + callbackParamVal);
         tester.getContext().setInitParameter("com.codahale.metrics.servlets.MetricsServlet.jsonpCallback", callbackParamName);
         processRequest();
@@ -127,20 +127,20 @@ public class MetricsServletTest extends AbstractServletTest {
                 .isEqualTo("*");
         assertThat(response.getContent())
                 .isEqualTo(callbackParamVal + "({" +
-                                   "\"version\":\"3.1.3\"," +
-                                   "\"gauges\":{" +
-                                       "\"g1\":{\"value\":100}" +
-                                   "}," +
-                                   "\"counters\":{" +
-                                       "\"c\":{\"count\":1}" +
-                                   "}," +
-                                   "\"histograms\":{" +
-                                       "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
-                                   "}," +
-                                   "\"meters\":{" +
-                                       "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
-                                   "}" +
-                               "})");
+                        "\"version\":\"4.0.0\"," +
+                        "\"gauges\":{" +
+                        "\"g1\":{\"value\":100}" +
+                        "}," +
+                        "\"counters\":{" +
+                        "\"c\":{\"count\":1}" +
+                        "}," +
+                        "\"histograms\":{" +
+                        "\"h\":{\"count\":1,\"max\":1,\"mean\":1.0,\"min\":1,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0}" +
+                        "}," +
+                        "\"meters\":{" +
+                        "\"m\":{\"count\":1,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":3333333.3333333335,\"units\":\"events/second\"}},\"timers\":{\"t\":{\"count\":1,\"max\":1.0,\"mean\":1.0,\"min\":1.0,\"p50\":1.0,\"p75\":1.0,\"p95\":1.0,\"p98\":1.0,\"p99\":1.0,\"p999\":1.0,\"stddev\":0.0,\"m15_rate\":0.0,\"m1_rate\":0.0,\"m5_rate\":0.0,\"mean_rate\":1.0E7,\"duration_units\":\"seconds\",\"rate_units\":\"calls/second\"}" +
+                        "}" +
+                        "})");
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
                 .isEqualTo("application/json");
     }
@@ -157,64 +157,64 @@ public class MetricsServletTest extends AbstractServletTest {
                 .isEqualTo("*");
         assertThat(response.getContent())
                 .isEqualTo(String.format("{%n" +
-                                                 "  \"version\" : \"3.1.3\",%n" +
-                                                 "  \"gauges\" : {%n" +
-                                                 "    \"g1\" : {%n" +
-                                                 "      \"value\" : 100%n" +
-                                                 "    }%n" +
-                                                 "  },%n" +
-                                                 "  \"counters\" : {%n" +
-                                                 "    \"c\" : {%n" +
-                                                 "      \"count\" : 1%n" +
-                                                 "    }%n" +
-                                                 "  },%n" +
-                                                 "  \"histograms\" : {%n" +
-                                                 "    \"h\" : {%n" +
-                                                 "      \"count\" : 1,%n" +
-                                                 "      \"max\" : 1,%n" +
-                                                 "      \"mean\" : 1.0,%n" +
-                                                 "      \"min\" : 1,%n" +
-                                                 "      \"p50\" : 1.0,%n" +
-                                                 "      \"p75\" : 1.0,%n" +
-                                                 "      \"p95\" : 1.0,%n" +
-                                                 "      \"p98\" : 1.0,%n" +
-                                                 "      \"p99\" : 1.0,%n" +
-                                                 "      \"p999\" : 1.0,%n" +
-                                                 "      \"stddev\" : 0.0%n" +
-                                                 "    }%n" +
-                                                 "  },%n" +
-                                                 "  \"meters\" : {%n" +
-                                                 "    \"m\" : {%n" +
-                                                 "      \"count\" : 1,%n" +
-                                                 "      \"m15_rate\" : 0.0,%n" +
-                                                 "      \"m1_rate\" : 0.0,%n" +
-                                                 "      \"m5_rate\" : 0.0,%n" +
-                                                 "      \"mean_rate\" : 3333333.3333333335,%n" +
-                                                 "      \"units\" : \"events/second\"%n" +
-                                                 "    }%n" +
-                                                 "  },%n" +
-                                                 "  \"timers\" : {%n" +
-                                                 "    \"t\" : {%n" +
-                                                 "      \"count\" : 1,%n" +
-                                                 "      \"max\" : 1.0,%n" +
-                                                 "      \"mean\" : 1.0,%n" +
-                                                 "      \"min\" : 1.0,%n" +
-                                                 "      \"p50\" : 1.0,%n" +
-                                                 "      \"p75\" : 1.0,%n" +
-                                                 "      \"p95\" : 1.0,%n" +
-                                                 "      \"p98\" : 1.0,%n" +
-                                                 "      \"p99\" : 1.0,%n" +
-                                                 "      \"p999\" : 1.0,%n" +
-                                                 "      \"stddev\" : 0.0,%n" +
-                                                 "      \"m15_rate\" : 0.0,%n" +
-                                                 "      \"m1_rate\" : 0.0,%n" +
-                                                 "      \"m5_rate\" : 0.0,%n" +
-                                                 "      \"mean_rate\" : 1.0E7,%n" +
-                                                 "      \"duration_units\" : \"seconds\",%n" +
-                                                 "      \"rate_units\" : \"calls/second\"%n" +
-                                                 "    }%n" +
-                                                 "  }%n" +
-                                                 "}"));
+                        "  \"version\" : \"4.0.0\",%n" +
+                        "  \"gauges\" : {%n" +
+                        "    \"g1\" : {%n" +
+                        "      \"value\" : 100%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"counters\" : {%n" +
+                        "    \"c\" : {%n" +
+                        "      \"count\" : 1%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"histograms\" : {%n" +
+                        "    \"h\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"max\" : 1,%n" +
+                        "      \"mean\" : 1.0,%n" +
+                        "      \"min\" : 1,%n" +
+                        "      \"p50\" : 1.0,%n" +
+                        "      \"p75\" : 1.0,%n" +
+                        "      \"p95\" : 1.0,%n" +
+                        "      \"p98\" : 1.0,%n" +
+                        "      \"p99\" : 1.0,%n" +
+                        "      \"p999\" : 1.0,%n" +
+                        "      \"stddev\" : 0.0%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"meters\" : {%n" +
+                        "    \"m\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"m15_rate\" : 0.0,%n" +
+                        "      \"m1_rate\" : 0.0,%n" +
+                        "      \"m5_rate\" : 0.0,%n" +
+                        "      \"mean_rate\" : 3333333.3333333335,%n" +
+                        "      \"units\" : \"events/second\"%n" +
+                        "    }%n" +
+                        "  },%n" +
+                        "  \"timers\" : {%n" +
+                        "    \"t\" : {%n" +
+                        "      \"count\" : 1,%n" +
+                        "      \"max\" : 1.0,%n" +
+                        "      \"mean\" : 1.0,%n" +
+                        "      \"min\" : 1.0,%n" +
+                        "      \"p50\" : 1.0,%n" +
+                        "      \"p75\" : 1.0,%n" +
+                        "      \"p95\" : 1.0,%n" +
+                        "      \"p98\" : 1.0,%n" +
+                        "      \"p99\" : 1.0,%n" +
+                        "      \"p999\" : 1.0,%n" +
+                        "      \"stddev\" : 0.0,%n" +
+                        "      \"m15_rate\" : 0.0,%n" +
+                        "      \"m1_rate\" : 0.0,%n" +
+                        "      \"m5_rate\" : 0.0,%n" +
+                        "      \"mean_rate\" : 1.0E7,%n" +
+                        "      \"duration_units\" : \"seconds\",%n" +
+                        "      \"rate_units\" : \"calls/second\"%n" +
+                        "    }%n" +
+                        "  }%n" +
+                        "}"));
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
                 .isEqualTo("application/json");
     }
@@ -240,7 +240,7 @@ public class MetricsServletTest extends AbstractServletTest {
         final ServletConfig servletConfig = mock(ServletConfig.class);
         when(servletConfig.getServletContext()).thenReturn(servletContext);
         when(servletContext.getAttribute(eq(MetricsServlet.METRICS_REGISTRY)))
-            .thenReturn(metricRegistry);
+                .thenReturn(metricRegistry);
 
         final MetricsServlet metricsServlet = new MetricsServlet(null);
         metricsServlet.init(servletConfig);
@@ -255,7 +255,7 @@ public class MetricsServletTest extends AbstractServletTest {
         final ServletConfig servletConfig = mock(ServletConfig.class);
         when(servletConfig.getServletContext()).thenReturn(servletContext);
         when(servletContext.getAttribute(eq(MetricsServlet.METRICS_REGISTRY)))
-            .thenReturn("IRELLEVANT_STRING");
+                .thenReturn("IRELLEVANT_STRING");
 
         final MetricsServlet metricsServlet = new MetricsServlet(null);
         metricsServlet.init(servletConfig);
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/PingServletTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/PingServletTest.java
index e0b2567..658e00a 100644
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/PingServletTest.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/PingServletTest.java
@@ -14,7 +14,7 @@ public class PingServletTest extends AbstractServletTest {
     }
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() throws Exception  {
         request.setMethod("GET");
         request.setURI("/ping");
         request.setVersion("HTTP/1.0");
@@ -23,25 +23,25 @@ public class PingServletTest extends AbstractServletTest {
     }
 
     @Test
-    public void returns200OK() throws Exception {
+    public void returns200OK()  {
         assertThat(response.getStatus())
                 .isEqualTo(200);
     }
 
     @Test
-    public void returnsPong() throws Exception {
+    public void returnsPong()  {
         assertThat(response.getContent())
                 .isEqualTo(String.format("pong%n"));
     }
 
     @Test
-    public void returnsTextPlain() throws Exception {
+    public void returnsTextPlain()  {
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
-                .isEqualTo("text/plain; charset=ISO-8859-1");
+                .isEqualTo("text/plain;charset=ISO-8859-1");
     }
 
     @Test
-    public void returnsUncacheable() throws Exception {
+    public void returnsUncacheable()  {
         assertThat(response.get(HttpHeader.CACHE_CONTROL))
                 .isEqualTo("must-revalidate,no-cache,no-store");
 
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/ThreadDumpServletTest.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/ThreadDumpServletTest.java
index b8b96dd..0ad5756 100644
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/ThreadDumpServletTest.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/ThreadDumpServletTest.java
@@ -23,25 +23,25 @@ public class ThreadDumpServletTest extends AbstractServletTest {
     }
 
     @Test
-    public void returns200OK() throws Exception {
+    public void returns200OK() {
         assertThat(response.getStatus())
                 .isEqualTo(200);
     }
 
     @Test
-    public void returnsAThreadDump() throws Exception {
+    public void returnsAThreadDump() {
         assertThat(response.getContent())
                 .contains("Finalizer");
     }
 
     @Test
-    public void returnsTextPlain() throws Exception {
+    public void returnsTextPlain() {
         assertThat(response.get(HttpHeader.CONTENT_TYPE))
                 .isEqualTo("text/plain");
     }
 
     @Test
-    public void returnsUncacheable() throws Exception {
+    public void returnsUncacheable() {
         assertThat(response.get(HttpHeader.CACHE_CONTROL))
                 .isEqualTo("must-revalidate,no-cache,no-store");
 
diff --git a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/experiments/ExampleServer.java b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/experiments/ExampleServer.java
index cb3c0cc..1193063 100644
--- a/metrics-servlets/src/test/java/com/codahale/metrics/servlets/experiments/ExampleServer.java
+++ b/metrics-servlets/src/test/java/com/codahale/metrics/servlets/experiments/ExampleServer.java
@@ -22,16 +22,12 @@ import static com.codahale.metrics.MetricRegistry.name;
 
 public class ExampleServer {
     private static final MetricRegistry REGISTRY = new MetricRegistry();
-    private static final Counter COUNTER_1 = REGISTRY.counter(name(ExampleServer.class,
-                                                                   "wah",
-                                                                   "doody"));
+    private static final Counter COUNTER_1 = REGISTRY.counter(name(ExampleServer.class, "wah", "doody"));
     private static final Counter COUNTER_2 = REGISTRY.counter(name(ExampleServer.class, "woo"));
+
     static {
-        REGISTRY.register(name(ExampleServer.class, "boo"), new Gauge<Integer>() {
-            @Override
-            public Integer getValue() {
-                throw new RuntimeException("asplode!");
-            }
+        REGISTRY.register(name(ExampleServer.class, "boo"), (Gauge<Integer>) () -> {
+            throw new RuntimeException("asplode!");
         });
     }
 
@@ -42,9 +38,8 @@ public class ExampleServer {
         final ThreadPool threadPool = new InstrumentedQueuedThreadPool(REGISTRY);
         final Server server = new Server(threadPool);
 
-        final Connector connector = new ServerConnector(server,
-                                                        new InstrumentedConnectionFactory(new HttpConnectionFactory(),
-                                                                                          REGISTRY.timer("http.connection")));
+        final Connector connector = new ServerConnector(server, new InstrumentedConnectionFactory(
+                new HttpConnectionFactory(), REGISTRY.timer("http.connection")));
         server.addConnector(connector);
 
         final ServletContextHandler context = new ServletContextHandler();
@@ -58,7 +53,7 @@ public class ExampleServer {
         final InstrumentedHandler handler = new InstrumentedHandler(REGISTRY);
         handler.setHandler(context);
         server.setHandler(handler);
-        
+
         server.start();
         server.join();
     }
diff --git a/mvnw b/mvnw
new file mode 100755
index 0000000..8d937f4
--- /dev/null
+++ b/mvnw
@@ -0,0 +1,308 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.2.0
+#
+# Required ENV vars:
+# ------------------
+#   JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+#   MAVEN_OPTS - parameters passed to the Java VM when running Maven
+#     e.g. to debug Maven itself, use
+#       set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+#   MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ] ; then
+
+  if [ -f /usr/local/etc/mavenrc ] ; then
+    . /usr/local/etc/mavenrc
+  fi
+
+  if [ -f /etc/mavenrc ] ; then
+    . /etc/mavenrc
+  fi
+
+  if [ -f "$HOME/.mavenrc" ] ; then
+    . "$HOME/.mavenrc"
+  fi
+
+fi
+
+# OS specific support.  $var _must_ be set to either true or false.
+cygwin=false;
+darwin=false;
+mingw=false
+case "$(uname)" in
+  CYGWIN*) cygwin=true ;;
+  MINGW*) mingw=true;;
+  Darwin*) darwin=true
+    # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
+    # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
+    if [ -z "$JAVA_HOME" ]; then
+      if [ -x "/usr/libexec/java_home" ]; then
+        JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME
+      else
+        JAVA_HOME="/Library/Java/Home"; export JAVA_HOME
+      fi
+    fi
+    ;;
+esac
+
+if [ -z "$JAVA_HOME" ] ; then
+  if [ -r /etc/gentoo-release ] ; then
+    JAVA_HOME=$(java-config --jre-home)
+  fi
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin ; then
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
+fi
+
+# For Mingw, ensure paths are in UNIX format before anything is touched
+if $mingw ; then
+  [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] &&
+    JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)"
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+  javaExecutable="$(which javac)"
+  if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then
+    # readlink(1) is not available as standard on Solaris 10.
+    readLink=$(which readlink)
+    if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then
+      if $darwin ; then
+        javaHome="$(dirname "\"$javaExecutable\"")"
+        javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac"
+      else
+        javaExecutable="$(readlink -f "\"$javaExecutable\"")"
+      fi
+      javaHome="$(dirname "\"$javaExecutable\"")"
+      javaHome=$(expr "$javaHome" : '\(.*\)/bin')
+      JAVA_HOME="$javaHome"
+      export JAVA_HOME
+    fi
+  fi
+fi
+
+if [ -z "$JAVACMD" ] ; then
+  if [ -n "$JAVA_HOME"  ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+      # IBM's JDK on AIX uses strange locations for the executables
+      JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+      JAVACMD="$JAVA_HOME/bin/java"
+    fi
+  else
+    JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)"
+  fi
+fi
+
+if [ ! -x "$JAVACMD" ] ; then
+  echo "Error: JAVA_HOME is not defined correctly." >&2
+  echo "  We cannot execute $JAVACMD" >&2
+  exit 1
+fi
+
+if [ -z "$JAVA_HOME" ] ; then
+  echo "Warning: JAVA_HOME environment variable is not set."
+fi
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+  if [ -z "$1" ]
+  then
+    echo "Path not specified to find_maven_basedir"
+    return 1
+  fi
+
+  basedir="$1"
+  wdir="$1"
+  while [ "$wdir" != '/' ] ; do
+    if [ -d "$wdir"/.mvn ] ; then
+      basedir=$wdir
+      break
+    fi
+    # workaround for JBEAP-8937 (on Solaris 10/Sparc)
+    if [ -d "${wdir}" ]; then
+      wdir=$(cd "$wdir/.." || exit 1; pwd)
+    fi
+    # end of workaround
+  done
+  printf '%s' "$(cd "$basedir" || exit 1; pwd)"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+  if [ -f "$1" ]; then
+    # Remove \r in case we run on Windows within Git Bash
+    # and check out the repository with auto CRLF management
+    # enabled. Otherwise, we may read lines that are delimited with
+    # \r\n and produce $'-Xarg\r' rather than -Xarg due to word
+    # splitting rules.
+    tr -s '\r\n' ' ' < "$1"
+  fi
+}
+
+log() {
+  if [ "$MVNW_VERBOSE" = true ]; then
+    printf '%s\n' "$1"
+  fi
+}
+
+BASE_DIR=$(find_maven_basedir "$(dirname "$0")")
+if [ -z "$BASE_DIR" ]; then
+  exit 1;
+fi
+
+MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR
+log "$MAVEN_PROJECTBASEDIR"
+
+##########################################################################################
+# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+# This allows using the maven wrapper in projects that prohibit checking in binary data.
+##########################################################################################
+wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar"
+if [ -r "$wrapperJarPath" ]; then
+    log "Found $wrapperJarPath"
+else
+    log "Couldn't find $wrapperJarPath, downloading it ..."
+
+    if [ -n "$MVNW_REPOURL" ]; then
+      wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
+    else
+      wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
+    fi
+    while IFS="=" read -r key value; do
+      # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' )
+      safeValue=$(echo "$value" | tr -d '\r')
+      case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;;
+      esac
+    done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
+    log "Downloading from: $wrapperUrl"
+
+    if $cygwin; then
+      wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
+    fi
+
+    if command -v wget > /dev/null; then
+        log "Found wget ... using wget"
+        [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet"
+        if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+            wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+        else
+            wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+        fi
+    elif command -v curl > /dev/null; then
+        log "Found curl ... using curl"
+        [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent"
+        if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+            curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
+        else
+            curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
+        fi
+    else
+        log "Falling back to using Java to download"
+        javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java"
+        javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class"
+        # For Cygwin, switch paths to Windows format before running javac
+        if $cygwin; then
+          javaSource=$(cygpath --path --windows "$javaSource")
+          javaClass=$(cygpath --path --windows "$javaClass")
+        fi
+        if [ -e "$javaSource" ]; then
+            if [ ! -e "$javaClass" ]; then
+                log " - Compiling MavenWrapperDownloader.java ..."
+                ("$JAVA_HOME/bin/javac" "$javaSource")
+            fi
+            if [ -e "$javaClass" ]; then
+                log " - Running MavenWrapperDownloader.java ..."
+                ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath"
+            fi
+        fi
+    fi
+fi
+##########################################################################################
+# End of extension
+##########################################################################################
+
+# If specified, validate the SHA-256 sum of the Maven wrapper jar file
+wrapperSha256Sum=""
+while IFS="=" read -r key value; do
+  case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;;
+  esac
+done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
+if [ -n "$wrapperSha256Sum" ]; then
+  wrapperSha256Result=false
+  if command -v sha256sum > /dev/null; then
+    if echo "$wrapperSha256Sum  $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then
+      wrapperSha256Result=true
+    fi
+  elif command -v shasum > /dev/null; then
+    if echo "$wrapperSha256Sum  $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then
+      wrapperSha256Result=true
+    fi
+  else
+    echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available."
+    echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties."
+    exit 1
+  fi
+  if [ $wrapperSha256Result = false ]; then
+    echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2
+    echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2
+    echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2
+    exit 1
+  fi
+fi
+
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+  [ -n "$JAVA_HOME" ] &&
+    JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
+  [ -n "$CLASSPATH" ] &&
+    CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
+  [ -n "$MAVEN_PROJECTBASEDIR" ] &&
+    MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
+fi
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+# shellcheck disable=SC2086 # safe args
+exec "$JAVACMD" \
+  $MAVEN_OPTS \
+  $MAVEN_DEBUG_OPTS \
+  -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+  "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+  ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 0000000..f80fbad
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,205 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.2.0
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM     e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on"  echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
+if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo.
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo.
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
+
+FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+    IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
+)
+
+@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
+if exist %WRAPPER_JAR% (
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Found %WRAPPER_JAR%
+    )
+) else (
+    if not "%MVNW_REPOURL%" == "" (
+        SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
+    )
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Couldn't find %WRAPPER_JAR%, downloading it ...
+        echo Downloading from: %WRAPPER_URL%
+    )
+
+    powershell -Command "&{"^
+		"$webclient = new-object System.Net.WebClient;"^
+		"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
+		"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
+		"}"^
+		"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
+		"}"
+    if "%MVNW_VERBOSE%" == "true" (
+        echo Finished downloading %WRAPPER_JAR%
+    )
+)
+@REM End of extension
+
+@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
+SET WRAPPER_SHA_256_SUM=""
+FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+    IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
+)
+IF NOT %WRAPPER_SHA_256_SUM%=="" (
+    powershell -Command "&{"^
+       "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
+       "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
+       "  Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
+       "  Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
+       "  Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
+       "  exit 1;"^
+       "}"^
+       "}"
+    if ERRORLEVEL 1 goto error
+)
+
+@REM Provide a "standardized" way to retrieve the CLI args that will
+@REM work with both Windows and non-Windows executions.
+set MAVEN_CMD_LINE_ARGS=%*
+
+%MAVEN_JAVA_EXE% ^
+  %JVM_CONFIG_MAVEN_PROPS% ^
+  %MAVEN_OPTS% ^
+  %MAVEN_DEBUG_OPTS% ^
+  -classpath %WRAPPER_JAR% ^
+  "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
+  %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
+if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%"=="on" pause
+
+if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
+
+cmd /C exit /B %ERROR_CODE%
diff --git a/pom.xml b/pom.xml
index 3c3b3c1..87169f2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,58 +1,80 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <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>
-    <prerequisites>
-        <maven>3.0.1</maven>
-    </prerequisites>
 
     <groupId>io.dropwizard.metrics</groupId>
     <artifactId>metrics-parent</artifactId>
-    <version>3.2.6</version>
+    <version>4.2.25</version>
     <packaging>pom</packaging>
     <name>Metrics Parent</name>
     <description>
         The Metrics library.
     </description>
-    <url>http://metrics.dropwizard.io/</url>
+    <url>https://metrics.dropwizard.io</url>
 
     <modules>
         <module>docs</module>
+        <module>metrics-bom</module>
         <module>metrics-annotation</module>
         <module>metrics-benchmarks</module>
+        <module>metrics-caffeine</module>
+        <module>metrics-caffeine3</module>
         <module>metrics-core</module>
-        <module>metrics-healthchecks</module>
+        <module>metrics-collectd</module>
         <module>metrics-ehcache</module>
-        <module>metrics-ganglia</module>
         <module>metrics-graphite</module>
+        <module>metrics-healthchecks</module>
         <module>metrics-httpclient</module>
+        <module>metrics-httpclient5</module>
         <module>metrics-httpasyncclient</module>
+        <module>metrics-jakarta-servlet</module>
+        <module>metrics-jakarta-servlet6</module>
+        <module>metrics-jakarta-servlets</module>
         <module>metrics-jcache</module>
+        <module>metrics-jcstress</module>
         <module>metrics-jdbi</module>
-        <module>metrics-jersey</module>
+        <module>metrics-jdbi3</module>
         <module>metrics-jersey2</module>
-        <module>metrics-jetty8</module>
+        <module>metrics-jersey3</module>
+        <module>metrics-jersey31</module>
         <module>metrics-jetty9</module>
-        <module>metrics-jetty9-legacy</module>
+        <module>metrics-jetty10</module>
+        <module>metrics-jetty11</module>
+        <module>metrics-jmx</module>
         <module>metrics-json</module>
         <module>metrics-jvm</module>
-        <module>metrics-log4j</module>
         <module>metrics-log4j2</module>
         <module>metrics-logback</module>
+        <module>metrics-logback13</module>
+        <module>metrics-logback14</module>
         <module>metrics-servlet</module>
         <module>metrics-servlets</module>
-        <module>metrics-jcstress</module>
-  </modules>
+    </modules>
 
     <properties>
+        <project.build.outputTimestamp>2024-01-24T22:47:57Z</project.build.outputTimestamp>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
-        <servlet.version>3.1.0</servlet.version>
-        <slf4j.version>1.7.22</slf4j.version>
-        <jackson.version>2.6.6</jackson.version>
-        <jetty8.version>8.1.11.v20130520</jetty8.version>
-        <jetty9.legacy.version>9.0.4.v20130625</jetty9.legacy.version>
-        <jetty9.version>9.2.2.v20140723</jetty9.version>
-        <rabbitmq.version>3.6.6</rabbitmq.version>
+
+        <jetty9.version>9.4.53.v20231009</jetty9.version>
+        <jetty10.version>10.0.19</jetty10.version>
+        <jetty11.version>11.0.19</jetty11.version>
+        <jetty12.version>12.0.5</jetty12.version>
+        <slf4j.version>1.7.36</slf4j.version>
+        <assertj.version>3.25.2</assertj.version>
+        <byte-buddy.version>1.14.11</byte-buddy.version>
+        <mockito.version>5.9.0</mockito.version>
+        <junit.version>4.13.1</junit.version>
+        <commons-lang3.version>3.14.0</commons-lang3.version>
+        <maven-compiler-plugin.version>3.12.1</maven-compiler-plugin.version>
+        <errorprone.version>2.24.1</errorprone.version>
+        <errorprone.javac.version>9+181-r4173-1</errorprone.javac.version>
+        <servlet6.version>6.0.0</servlet6.version>
+
+        <sonar.projectKey>dropwizard_metrics</sonar.projectKey>
+        <sonar.organization>dropwizard</sonar.organization>
+        <sonar.host.url>https://sonarcloud.io</sonar.host.url>
+        <sonar.moduleKey>${project.artifactId}</sonar.moduleKey>
     </properties>
 
     <developers>
@@ -72,12 +94,20 @@
                 <role>committer</role>
             </roles>
         </developer>
+        <developer>
+            <name>Artem Prigoda</name>
+            <email>prigoda.artem@ya.ru</email>
+            <timezone>Europe/Berlin</timezone>
+            <roles>
+                <role>committer</role>
+            </roles>
+        </developer>
     </developers>
 
     <licenses>
         <license>
             <name>Apache License 2.0</name>
-            <url>http://www.apache.org/licenses/LICENSE-2.0.html</url>
+            <url>https://www.apache.org/licenses/LICENSE-2.0.html</url>
             <distribution>repo</distribution>
         </license>
     </licenses>
@@ -85,85 +115,85 @@
     <scm>
         <connection>scm:git:git://github.com/dropwizard/metrics.git</connection>
         <developerConnection>scm:git:git@github.com:dropwizard/metrics.git</developerConnection>
-        <url>http://github.com/dropwizard/metrics/</url>
-        <tag>v3.2.6</tag>
+        <url>https://github.com/dropwizard/metrics/</url>
+        <tag>v4.2.25</tag>
     </scm>
 
     <issueManagement>
         <system>github</system>
-        <url>http://github.com/dropwizard/metrics/issues#issue/</url>
+        <url>https://github.com/dropwizard/metrics/issues/</url>
     </issueManagement>
 
     <distributionManagement>
         <snapshotRepository>
-            <id>sonatype-nexus-snapshots</id>
+            <id>ossrh</id>
             <name>Sonatype Nexus Snapshots</name>
-            <url>http://oss.sonatype.org/content/repositories/snapshots</url>
+            <url>https://s01.oss.sonatype.org/content/repositories/snapshots</url>
         </snapshotRepository>
         <repository>
-            <id>sonatype-nexus-staging</id>
+            <id>ossrh</id>
             <name>Nexus Release Repository</name>
-            <url>http://oss.sonatype.org/service/local/staging/deploy/maven2/</url>
+            <url>https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/</url>
         </repository>
     </distributionManagement>
 
-    <dependencyManagement>
-        <dependencies>
-            <dependency>
-                <groupId>org.slf4j</groupId>
-                <artifactId>slf4j-api</artifactId>
-                <version>${slf4j.version}</version>
-            </dependency>
-        </dependencies>
-    </dependencyManagement>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.slf4j</groupId>
-            <artifactId>slf4j-api</artifactId>
-        </dependency>
-        <dependency>
-            <groupId>junit</groupId>
-            <artifactId>junit</artifactId>
-            <version>4.11</version>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.assertj</groupId>
-            <artifactId>assertj-core</artifactId>
-            <version>1.6.1</version>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.mockito</groupId>
-            <artifactId>mockito-all</artifactId>
-            <version>1.9.5</version>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.slf4j</groupId>
-            <artifactId>slf4j-simple</artifactId>
-            <version>${slf4j.version}</version>
-            <scope>test</scope>
-        </dependency>
-    </dependencies>
-
     <profiles>
         <profile>
-            <id>doclint-java8-disable</id>
+            <id>jdk8</id>
             <activation>
-                <jdk>[1.8,)</jdk>
+                <jdk>1.8</jdk>
             </activation>
-
             <build>
                 <plugins>
-                    <!-- java 8 doclint html verification is excluded to suppress strict HTML 4.0 compliance errors 
-                         like "error: self-closing element not allowed <p />"   -->
                     <plugin>
                         <groupId>org.apache.maven.plugins</groupId>
-                        <artifactId>maven-javadoc-plugin</artifactId>
+                        <artifactId>maven-compiler-plugin</artifactId>
+                        <configuration>
+                            <compilerArgs combine.children="append">
+                                <arg>-J-Xbootclasspath/p:${settings.localRepository}/com/google/errorprone/javac/${errorprone.javac.version}/javac-${errorprone.javac.version}.jar</arg>
+                            </compilerArgs>
+                        </configuration>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
+        <profile>
+            <id>jdk17</id>
+            <activation>
+                <jdk>[17,)</jdk>
+            </activation>
+            <modules>
+                <module>metrics-jetty12</module>
+                <module>metrics-jetty12-ee10</module>
+            </modules>
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-compiler-plugin</artifactId>
                         <configuration>
-                            <additionalparam>-Xdoclint:all -Xdoclint:-html</additionalparam>
+                            <compilerArgs>
+                                <arg>-Xlint:all</arg>
+                                <arg>-XDcompilePolicy=simple</arg>
+                                <arg>-Xplugin:ErrorProne -XepExcludedPaths:.*/target/generated-sources/.*</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
+                                <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
+                                <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
+                                <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
+                            </compilerArgs>
+                            <annotationProcessorPaths>
+                                <path>
+                                    <groupId>com.google.errorprone</groupId>
+                                    <artifactId>error_prone_core</artifactId>
+                                    <version>${errorprone.version}</version>
+                                </path>
+                            </annotationProcessorPaths>
                         </configuration>
                     </plugin>
                 </plugins>
@@ -177,15 +207,44 @@
                     <value>true</value>
                 </property>
             </activation>
+            <properties>
+                <gpg.keyname>EDA86E9FB607B5FC9223FB767D4868B53E31E7AD</gpg.keyname>
+            </properties>
             <build>
                 <plugins>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-source-plugin</artifactId>
+                        <version>3.3.0</version>
+                        <executions>
+                            <execution>
+                                <id>attach-sources</id>
+                                <goals>
+                                    <goal>jar-no-fork</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-javadoc-plugin</artifactId>
+                        <executions>
+                            <execution>
+                                <id>attach-javadocs</id>
+                                <goals>
+                                    <goal>jar</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
                     <plugin>
                         <groupId>org.apache.maven.plugins</groupId>
                         <artifactId>maven-gpg-plugin</artifactId>
-                        <version>1.6</version>
+                        <version>3.1.0</version>
                         <configuration>
                             <gpgArguments>
-                                <argument>--no-tty</argument>
+                                <arg>--pinentry-mode</arg>
+                                <arg>loopback</arg>
                             </gpgArguments>
                         </configuration>
                         <executions>
@@ -198,26 +257,97 @@
                             </execution>
                         </executions>
                     </plugin>
+                    <plugin>
+                        <groupId>org.sonatype.plugins</groupId>
+                        <artifactId>nexus-staging-maven-plugin</artifactId>
+                        <version>1.6.13</version>
+                        <configuration>
+                            <serverId>ossrh</serverId>
+                            <nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
+                            <autoReleaseAfterClose>true</autoReleaseAfterClose>
+                        </configuration>
+                        <executions>
+                            <execution>
+                                <id>nexus-deploy</id>
+                                <phase>deploy</phase>
+                                <goals>
+                                    <goal>deploy</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
+                    <plugin>
+                        <groupId>org.cyclonedx</groupId>
+                        <artifactId>cyclonedx-maven-plugin</artifactId>
+                        <version>2.7.11</version>
+                        <executions>
+                            <execution>
+                                <phase>package</phase>
+                                <goals>
+                                    <goal>makeAggregateBom</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
                 </plugins>
             </build>
         </profile>
     </profiles>
 
     <build>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-compiler-plugin</artifactId>
+                    <version>${maven-compiler-plugin.version}</version>
+                </plugin>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-deploy-plugin</artifactId>
+                    <version>3.1.1</version>
+                </plugin>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-javadoc-plugin</artifactId>
+                    <version>3.6.3</version>
+                    <configuration>
+                        <source>8</source>
+                        <doclint>none</doclint>
+                        <quiet>true</quiet>
+                        <notimestamp>true</notimestamp>
+                        <legacyMode>true</legacyMode>
+                    </configuration>
+                </plugin>
+            </plugins>
+        </pluginManagement>
         <plugins>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
-                <version>3.1</version>
                 <configuration>
-                    <source>1.6</source>
-                    <target>1.6</target>
+                    <release>8</release>
+                    <fork>true</fork>
+                    <parameters>true</parameters>
+                    <showWarnings>true</showWarnings>
+                    <compilerArgs>
+                        <arg>-Xlint:all</arg>
+                        <arg>-XDcompilePolicy=simple</arg>
+                        <arg>-Xplugin:ErrorProne -XepExcludedPaths:.*/target/generated-sources/.*</arg>
+                    </compilerArgs>
+                    <annotationProcessorPaths>
+                        <path>
+                            <groupId>com.google.errorprone</groupId>
+                            <artifactId>error_prone_core</artifactId>
+                            <version>${errorprone.version}</version>
+                        </path>
+                    </annotationProcessorPaths>
                 </configuration>
             </plugin>
             <plugin>
                 <groupId>org.apache.felix</groupId>
                 <artifactId>maven-bundle-plugin</artifactId>
-                <version>2.3.7</version>
+                <version>5.1.9</version>
                 <extensions>true</extensions>
                 <configuration>
                     <instructions>
@@ -225,37 +355,26 @@
                             javax.servlet*;version="[2.5.0,4.0.0)",
                             org.slf4j*;version="[1.6.0,2.0.0)",
                             sun.misc.*;resolution:=optional,
+                            com.sun.management.*;resolution:=optional,
                             *
                         ]]>
                         </Import-Package>
+                        <Automatic-Module-Name>${javaModuleName}</Automatic-Module-Name>
                     </instructions>
                 </configuration>
             </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
-                <version>2.14.1</version>
+                <version>3.2.5</version>
                 <configuration>
-                    <parallel>classes</parallel>
+                    <argLine>@{argLine} -Djava.net.preferIPv4Stack=true</argLine>
                 </configuration>
             </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-source-plugin</artifactId>
-                <version>2.2.1</version>
-                <executions>
-                    <execution>
-                        <id>attach-sources</id>
-                        <goals>
-                            <goal>jar</goal>
-                        </goals>
-                    </execution>
-                </executions>
-            </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-enforcer-plugin</artifactId>
-                <version>1.2</version>
+                <version>3.4.1</version>
                 <executions>
                     <execution>
                         <id>enforce</id>
@@ -272,21 +391,28 @@
             </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-javadoc-plugin</artifactId>
-                <version>2.9</version>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>3.6.1</version>
                 <executions>
                     <execution>
-                        <id>attach-javadocs</id>
+                        <id>analyze</id>
                         <goals>
-                            <goal>jar</goal>
+                            <goal>analyze-only</goal>
+                            <goal>analyze-dep-mgt</goal>
+                            <goal>analyze-duplicate</goal>
                         </goals>
+                        <phase>verify</phase>
+                        <configuration>
+                            <failOnWarning>true</failOnWarning>
+                            <ignoreNonCompile>true</ignoreNonCompile>
+                        </configuration>
                     </execution>
                 </executions>
             </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-release-plugin</artifactId>
-                <version>2.5.3</version>
+                <version>3.0.1</version>
                 <configuration>
                     <autoVersionSubmodules>true</autoVersionSubmodules>
                     <mavenExecutorId>forked-path</mavenExecutorId>
@@ -294,35 +420,59 @@
                     <preparationGoals>clean test</preparationGoals>
                 </configuration>
             </plugin>
-            <plugin>
-                <groupId>org.codehaus.mojo</groupId>
-                <artifactId>findbugs-maven-plugin</artifactId>
-                <version>3.0.0</version>
-                <configuration>
-                    <effort>Max</effort>
-                    <threshold>Default</threshold>
-                    <xmlOutput>true</xmlOutput>
-                </configuration>
-                <executions>
-                    <execution>
-                        <goals>
-                            <goal>check</goal>
-                        </goals>
-                    </execution>
-                </executions>
-            </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-jar-plugin</artifactId>
-                <version>2.4</version>
+                <version>3.3.0</version>
                 <configuration>
                     <archive>
                         <manifest>
                             <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                         </manifest>
+                        <manifestEntries>
+                            <Automatic-Module-Name>${javaModuleName}</Automatic-Module-Name>
+                        </manifestEntries>
                     </archive>
                 </configuration>
             </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-checkstyle-plugin</artifactId>
+                <version>3.3.1</version>
+                <configuration>
+                    <configLocation>checkstyle.xml</configLocation>
+                    <includeTestSourceDirectory>true</includeTestSourceDirectory>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-site-plugin</artifactId>
+                <version>3.12.1</version>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-project-info-reports-plugin</artifactId>
+                <version>3.5.0</version>
+            </plugin>
+            <plugin>
+                <groupId>org.jacoco</groupId>
+                <artifactId>jacoco-maven-plugin</artifactId>
+                <version>0.8.11</version>
+                <executions>
+                    <execution>
+                        <id>prepare-agent</id>
+                        <goals>
+                            <goal>prepare-agent</goal>
+                        </goals>
+                    </execution>
+                    <execution>
+                        <id>report</id>
+                        <goals>
+                            <goal>report</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
         </plugins>
     </build>
 </project>
diff --git a/prepare_docs.sh b/prepare_docs.sh
new file mode 100755
index 0000000..1130258
--- /dev/null
+++ b/prepare_docs.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+#
+# Builds the documentation of the Metrics project for the specified
+# release, copies and commits it to the local gh-pages branch.
+#
+# Usage: ./prepare_docs.sh v1.0.1 1.0.1
+#
+
+set -e
+
+[[ "$#" < 2 ]] && { echo "No release branch and number are specified"; exit 1; }
+
+release_branch="$1"
+release_number="$2"
+
+echo -e "\nGenerating Dropwizard documentation"
+echo "Release branch: $release_branch"
+echo "Release number: $release_number"
+
+echo -e "\n-------------------------------"
+echo "Moving to $release_branch branch"
+echo "-------------------------------"
+
+git checkout "$release_branch"
+
+echo -e "\n-------------------------------"
+echo "Generating documentation"
+echo -e "-------------------------------\n"
+
+cd docs/
+
+echo -e "\n-------------------------------"
+echo "Staging documentation"
+echo "-------------------------------"
+mvn clean package
+mvn site:site
+
+echo -e "\n-------------------------------"
+echo "Moving to the gh-pages branch"
+echo -e "-------------------------------\n"
+git checkout gh-pages
+
+echo -e "\n-------------------------------"
+echo "Creating a directory for documentation"
+echo -e "-------------------------------\n"
+mkdir "$release_number"
+
+echo -e "\n-------------------------------"
+echo "Copy documentation"
+echo -e "-------------------------------\n"
+cd ../
+cp -r docs/target/site/* "${release_number}"/
+
+echo -e "\n-------------------------------"
+echo "Add and commit changes to the repository"
+echo -e "-------------------------------\n"
+git add .
+git commit -m "Add docs for Dropwizard $release_number"
+
+echo -e "\nDone!"
+echo "Please review changes and push them with if they look good"
+exit $?
+
diff --git a/qodana.yaml b/qodana.yaml
new file mode 100644
index 0000000..112c0b0
--- /dev/null
+++ b/qodana.yaml
@@ -0,0 +1,7 @@
+---
+# https://www.jetbrains.com/help/qodana/qodana-yaml.html
+version: "1.0"
+profile:
+  name: qodana.starter
+projectJDK: 17
+linter: jetbrains/qodana-jvm-community:2023.2
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..acea8eb
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,11 @@
+{
+  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+  "extends": [
+    "local>dropwizard/renovate-config"
+  ],
+  "baseBranches": ["release/4.2.x", "release/5.0.x"],
+  "vulnerabilityAlerts": {
+    "labels": ["security"],
+    "assignees": ["team:committers", "team:metrics"]
+  }
+}

More details

Full run details

Historical runs