diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,97 @@
+## 0.4.0.0 (2014-05-01)
+
+ * Lots of the internals were split off into a new package, ekg-core.
+
+ * The `Gauge.modify` function was removed, as it can't be supported
+   by the new, more efficient implementation of gauges.
+
+ * The JSON API was significantly overhauled. The the Haddock
+   documentation for details.
+
+ * The metric store used internally by the server is now exposed and
+   can be used to share the same metric store between ekg and e.g.
+   ekg-statsd.
+
+ * It's now possible to provide a custom metric store to the server.
+
+ * The getDistribution function was added.
+
+ * The UI now has less special treatment for built-in metrics.
+
+## 0.3.1.3 (2013-02-22)
+
+ * Fixed security issue where ekg would always listen to all incoming
+   requests, even if "localhost" was specified.
+
+ * Always export par_tot_bytes_copied. Previously it was only exported
+   if using base-4.6 and later.
+
+## 0.3.1.2 (2012-09-18)
+
+ * Support GHC 7.6
+
+## 0.3.1.1 (2012-06-25)
+
+ * Bump dependencies.
+
+## 0.3.1.0 (2012-04-17)
+
+ * Add labels, which are free-form string values exported by the
+   monitoring server.  Labels allow you to export e.g. the command
+   line arguments used to start the executable or the host name it's
+   running on.
+
+## 0.3.0.4 (2012-04-03)
+
+ * Add original JavaScript files to tarball to ease distribution
+   packaging.
+
+## 0.3.0.4 (2012-04-03)
+
+ * Change icons to Creative Commons Attribution 3.0 licensed one
+
+## 0.3.0.3 (2012-03-19)
+
+ * Support Snap 0.8
+
+## 0.3.0.2 (2012-03-07)
+
+ * Don't require an internet connection, by serving Bootstrap CSS and
+   jQuery from the monitoring server.
+
+## 0.3.0.1 (2012-01-26)
+
+ * Switch from Blueprint to Bootstrap CSS
+
+ * Overhaul look-and-feel
+
+## 0.3.0.0 (2012-01-01)
+
+ * Add gauges and change counters to always be monotonically increasing
+
+ * Add web interface column headers
+
+ * Reorganize the web interface to show counters and gauges in separate sections
+
+ * Change REST API to allow separate access to counters and gauges
+
+ * Return the server time in the JSON response it and use server time
+   instead of client time when graphing
+
+ * Make it possible to graph user-defined counters and gauges
+
+ * Format numbers using comma separators
+
+ * Show a message box when the server can't be reached
+
+## 0.2.0.0 (2011-12-30)
+
+ * Add user-defined counters
+
+ * Suppress Snap logging to stdio
+
+ * Add REST-style API for accessing single counters
+
+## 0.1.0.0 (2011-12-27)
+
+ * First EKG release
diff --git a/System/Remote/Common.hs b/System/Remote/Common.hs
deleted file mode 100644
--- a/System/Remote/Common.hs
+++ /dev/null
@@ -1,408 +0,0 @@
-{-# LANGUAGE CPP #-}
-{-# LANGUAGE ExistentialQuantification #-}
-{-# LANGUAGE FunctionalDependencies #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RecordWildCards #-}
-
-module System.Remote.Common
-    (
-      -- * Types
-      Counters
-    , Gauges
-    , Labels
-    , Server(..)
-
-    , Ref(..)
-
-      -- * User-defined counters, gauges, and labels
-    , getCounter
-    , getGauge
-    , getLabel
-
-    , buildMany
-    , buildAll
-    , buildCombined
-    , buildOne
-
-    , parseHttpAccept
-    ) where
-
-import Control.Concurrent (ThreadId)
-import Control.Monad (forM)
-import Control.Monad.IO.Class (liftIO)
-import qualified Data.Aeson.Encode as A
-import Data.Aeson.Types ((.=))
-import qualified Data.Aeson.Types as A
-import qualified Data.ByteString as S
-import qualified Data.ByteString.Char8 as S8
-import qualified Data.ByteString.Lazy as L
-import Data.Function (on)
-import qualified Data.HashMap.Strict as M
-import Data.IORef (IORef, atomicModifyIORef, readIORef)
-import Data.Int (Int64)
-import qualified Data.List as List
-import qualified Data.Map as Map
-import qualified Data.Text as T
-import Data.Time.Clock.POSIX (getPOSIXTime)
-import Data.Word (Word8)
-import qualified GHC.Stats as Stats
-import Prelude hiding (read)
-
-import System.Remote.Counter (Counter)
-import qualified System.Remote.Counter.Internal as Counter
-import System.Remote.Gauge (Gauge)
-import qualified System.Remote.Gauge.Internal as Gauge
-import System.Remote.Label (Label)
-import qualified System.Remote.Label.Internal as Label
-
-------------------------------------------------------------------------
-
--- Map of user-defined counters.
-type Counters = M.HashMap T.Text Counter
-
--- Map of user-defined gauges.
-type Gauges = M.HashMap T.Text Gauge
-
--- Map of user-defined labels.
-type Labels = M.HashMap T.Text Label
-
--- | A handle that can be used to control the monitoring server.
--- Created by 'forkServer'.
-data Server = Server {
-      threadId :: {-# UNPACK #-} !ThreadId
-    , userCounters :: !(IORef Counters)
-    , userGauges :: !(IORef Gauges)
-    , userLabels :: !(IORef Labels)
-    }
-
-------------------------------------------------------------------------
--- * User-defined counters, gauges and labels
-
-class Ref r t | r -> t where
-    new :: IO r
-    read :: r -> IO t
-
-instance Ref Counter Int where
-    new = Counter.new
-    read = Counter.read
-
-instance Ref Gauge Int where
-    new = Gauge.new
-    read = Gauge.read
-
-instance Ref Label T.Text where
-    new = Label.new
-    read = Label.read
-
--- | Lookup a 'Ref' by name in the given map.  If no 'Ref' exists
--- under the given name, create a new one, insert it into the map and
--- return it.
-getRef :: Ref r t
-       => T.Text                      -- ^ 'Ref' name
-       -> IORef (M.HashMap T.Text r)  -- ^ Server that will serve the 'Ref'
-       -> IO r
-getRef name mapRef = do
-    empty <- new
-    ref <- atomicModifyIORef mapRef $ \ m ->
-        case M.lookup name m of
-            Nothing  -> let m' = M.insert name empty m
-                        in (m', empty)
-            Just ref -> (m, ref)
-    return ref
-{-# INLINABLE getRef #-}
-
--- | Return the counter associated with the given name and server.
--- Multiple calls to 'getCounter' with the same arguments will return
--- the same counter.  The first time 'getCounter' is called for a
--- given name and server, a new, zero-initialized counter will be
--- returned.
-getCounter :: T.Text  -- ^ Counter name
-           -> Server  -- ^ Server that will serve the counter
-           -> IO Counter
-getCounter name server = getRef name (userCounters server)
-
--- | Return the gauge associated with the given name and server.
--- Multiple calls to 'getGauge' with the same arguments will return
--- the same gauge.  The first time 'getGauge' is called for a given
--- name and server, a new, zero-initialized gauge will be returned.
-getGauge :: T.Text  -- ^ Gauge name
-         -> Server  -- ^ Server that will serve the gauge
-         -> IO Gauge
-getGauge name server = getRef name (userGauges server)
-
--- | Return the label associated with the given name and server.
--- Multiple calls to 'getLabel' with the same arguments will return
--- the same label.  The first time 'getLabel' is called for a given
--- name and server, a new, empty label will be returned.
-getLabel :: T.Text  -- ^ Label name
-         -> Server  -- ^ Server that will serve the label
-         -> IO Label
-getLabel name server = getRef name (userLabels server)
-
-------------------------------------------------------------------------
--- * JSON serialization
-
--- | All the stats exported by the server (i.e. GC stats plus user
--- defined counters).
-data Stats = Stats
-    !Stats.GCStats          -- GC statistics
-    ![(T.Text, Json)]       -- Counters
-    ![(T.Text, Json)]       -- Gauges
-    ![(T.Text, Json)]       -- Labels
-    {-# UNPACK #-} !Double  -- Milliseconds since epoch
-
-instance A.ToJSON Stats where
-    toJSON (Stats gcStats counters gauges labels t) = A.object $
-        [ "server_timestamp_millis" .= t
-        , "counters"                .= Assocs (gcCounters ++ counters)
-        , "gauges"                  .= Assocs (gcGauges ++ gauges)
-        , "labels"                  .= Assocs (labels)
-        ]
-      where
-        (gcCounters, gcGauges) = partitionGcStats gcStats
-
--- | 'Stats' encoded as a flattened JSON object.
-newtype Combined = Combined Stats
-
-instance A.ToJSON Combined where
-    toJSON (Combined (Stats s@(Stats.GCStats {..}) counters gauges labels t)) =
-        A.object $
-        [ "server_timestamp_millis"  .= t
-        , "bytes_allocated"          .= bytesAllocated
-        , "num_gcs"                  .= numGcs
-        , "max_bytes_used"           .= maxBytesUsed
-        , "num_bytes_usage_samples"  .= numByteUsageSamples
-        , "cumulative_bytes_used"    .= cumulativeBytesUsed
-        , "bytes_copied"             .= bytesCopied
-        , "current_bytes_used"       .= currentBytesUsed
-        , "current_bytes_slop"       .= currentBytesSlop
-        , "max_bytes_slop"           .= maxBytesSlop
-        , "peak_megabytes_allocated" .= peakMegabytesAllocated
-        , "mutator_cpu_seconds"      .= mutatorCpuSeconds
-        , "mutator_wall_seconds"     .= mutatorWallSeconds
-        , "gc_cpu_seconds"           .= gcCpuSeconds
-        , "gc_wall_seconds"          .= gcWallSeconds
-        , "cpu_seconds"              .= cpuSeconds
-        , "wall_seconds"             .= wallSeconds
-        , "par_tot_bytes_copied"     .= gcParTotBytesCopied s
-        , "par_avg_bytes_copied"     .= gcParTotBytesCopied s
-        , "par_max_bytes_copied"     .= parMaxBytesCopied
-        ] ++
-        map (uncurry (.=)) counters ++
-        map (uncurry (.=)) gauges ++
-        map (uncurry (.=)) labels
-
--- | A list of string keys and JSON-encodable values.  Used to render
--- a list of key-value pairs as a JSON object.
-newtype Assocs = Assocs [(T.Text, Json)]
-
-instance A.ToJSON Assocs where
-    toJSON (Assocs xs) = A.object $ map (uncurry (.=)) xs
-
--- | A group of either counters or gauges.
-data Group = Group
-     ![(T.Text, Json)]
-    {-# UNPACK #-} !Double  -- Milliseconds since epoch
-
-instance A.ToJSON Group where
-    toJSON (Group xs t) =
-        A.object $ ("server_timestamp_millis" .= t) : map (uncurry (.=)) xs
-
-------------------------------------------------------------------------
-
--- | Get a snapshot of all values.  Note that we're not guaranteed to
--- see a consistent snapshot of the whole map.
-readAllRefs :: (Ref r t, A.ToJSON t) => IORef (M.HashMap T.Text r)
-            -> IO [(T.Text, Json)]
-readAllRefs mapRef = do
-    m <- readIORef mapRef
-    forM (M.toList m) $ \ (name, ref) -> do
-        val <- read ref
-        return (name, Json val)
-{-# INLINABLE readAllRefs #-}
-
-
--- Existential wrapper used for OO-style polymorphism.
-data Json = forall a. A.ToJSON a => Json a
-
-instance A.ToJSON Json where
-    toJSON (Json x) = A.toJSON x
-
--- | Partition GC statistics into counters and gauges.
-partitionGcStats :: Stats.GCStats -> ([(T.Text, Json)], [(T.Text, Json)])
-partitionGcStats s@(Stats.GCStats {..}) = (counters, gauges)
-  where
-    counters = [
-          ("bytes_allocated"          , Json bytesAllocated)
-        , ("num_gcs"                  , Json numGcs)
-        , ("num_bytes_usage_samples"  , Json numByteUsageSamples)
-        , ("cumulative_bytes_used"    , Json cumulativeBytesUsed)
-        , ("bytes_copied"             , Json bytesCopied)
-        , ("mutator_cpu_seconds"      , Json mutatorCpuSeconds)
-        , ("mutator_wall_seconds"     , Json mutatorWallSeconds)
-        , ("gc_cpu_seconds"           , Json gcCpuSeconds)
-        , ("gc_wall_seconds"          , Json gcWallSeconds)
-        , ("cpu_seconds"              , Json cpuSeconds)
-        , ("wall_seconds"             , Json wallSeconds)
-        ]
-    gauges = [
-          ("max_bytes_used"           , Json maxBytesUsed)
-        , ("current_bytes_used"       , Json currentBytesUsed)
-        , ("current_bytes_slop"       , Json currentBytesSlop)
-        , ("max_bytes_slop"           , Json maxBytesSlop)
-        , ("peak_megabytes_allocated" , Json peakMegabytesAllocated)
-        , ("par_tot_bytes_copied"     , Json (gcParTotBytesCopied s))
-        , ("par_avg_bytes_copied"     , Json (gcParTotBytesCopied s))
-        , ("par_max_bytes_copied"     , Json parMaxBytesCopied)
-        ]
-
-------------------------------------------------------------------------
-
--- | Serve a collection of counters or gauges, as a JSON object.
-buildMany :: (Ref r t, A.ToJSON t) => IORef (M.HashMap T.Text r)
-    -> IO L.ByteString
-buildMany mapRef = do
-    list <- readAllRefs mapRef
-    time <- getTimeMillis
-    return $ A.encode $ A.toJSON $ Group list time
-{-# INLINABLE buildMany #-}
-
--- | Serve all counter, gauges and labels, built-in or not, as a
--- nested JSON object.
-buildAll :: IORef Counters -> IORef Gauges -> IORef Labels -> IO L.ByteString
-buildAll counters gauges labels = do
-    gcStats <- getGcStats
-    counterList <- readAllRefs counters
-    gaugeList <- readAllRefs gauges
-    labelList <- readAllRefs labels
-    time <- getTimeMillis
-    return $ A.encode $ A.toJSON $ Stats gcStats counterList gaugeList
-        labelList time
-
-buildCombined :: IORef Counters -> IORef Gauges -> IORef Labels -> IO L.ByteString
-buildCombined counters gauges labels = do
-    gcStats <- getGcStats
-    counterList <- readAllRefs counters
-    gaugeList <- readAllRefs gauges
-    labelList <- readAllRefs labels
-    time <- getTimeMillis
-    return $ A.encode $ A.toJSON $ Combined $
-        Stats gcStats counterList gaugeList labelList time
-
-buildOne :: (Ref r t, Show t)
-    => IORef (M.HashMap T.Text r) -> T.Text
-    -> IO (Maybe S.ByteString)
-buildOne refs name = do
-    m <- readIORef refs
-    case M.lookup name m of
-        Just counter -> do
-            val <- read counter
-            return $ Just $ S8.pack $ show val
-        Nothing ->
-            -- Try built-in (e.g. GC) refs
-            case Map.lookup name builtinCounters of
-                Just f -> do
-                    gcStats <- liftIO getGcStats
-                    return $ Just $ S8.pack $ f gcStats
-                Nothing -> return Nothing
-{-# INLINABLE buildOne #-}
-
-getGcStats :: IO Stats.GCStats
-#if MIN_VERSION_base(4,6,0)
-getGcStats = do
-    enabled <- Stats.getGCStatsEnabled
-    if enabled
-        then Stats.getGCStats
-        else return emptyGCStats
-
-emptyGCStats :: Stats.GCStats
-emptyGCStats = Stats.GCStats
-    { bytesAllocated         = 0
-    , numGcs                 = 0
-    , maxBytesUsed           = 0
-    , numByteUsageSamples    = 0
-    , cumulativeBytesUsed    = 0
-    , bytesCopied            = 0
-    , currentBytesUsed       = 0
-    , currentBytesSlop       = 0
-    , maxBytesSlop           = 0
-    , peakMegabytesAllocated = 0
-    , mutatorCpuSeconds      = 0
-    , mutatorWallSeconds     = 0
-    , gcCpuSeconds           = 0
-    , gcWallSeconds          = 0
-    , cpuSeconds             = 0
-    , wallSeconds            = 0
-    , parTotBytesCopied      = 0
-    , parMaxBytesCopied      = 0
-    }
-#else
-getGcStats = Stats.getGCStats
-#endif
-
--- | A list of all built-in (e.g. GC) counters, together with a
--- pretty-printing function for each.
-builtinCounters :: Map.Map T.Text (Stats.GCStats -> String)
-builtinCounters = Map.fromList [
-      ("bytes_allocated"          , show . Stats.bytesAllocated)
-    , ("num_gcs"                  , show . Stats.numGcs)
-    , ("max_bytes_used"           , show . Stats.maxBytesUsed)
-    , ("num_bytes_usage_samples"  , show . Stats.numByteUsageSamples)
-    , ("cumulative_bytes_used"    , show . Stats.cumulativeBytesUsed)
-    , ("bytes_copied"             , show . Stats.bytesCopied)
-    , ("current_bytes_used"       , show . Stats.currentBytesUsed)
-    , ("current_bytes_slop"       , show . Stats.currentBytesSlop)
-    , ("max_bytes_slop"           , show . Stats.maxBytesSlop)
-    , ("peak_megabytes_allocated" , show . Stats.peakMegabytesAllocated)
-    , ("mutator_cpu_seconds"      , show . Stats.mutatorCpuSeconds)
-    , ("mutator_wall_seconds"     , show . Stats.mutatorWallSeconds)
-    , ("gc_cpu_seconds"           , show . Stats.gcCpuSeconds)
-    , ("gc_wall_seconds"          , show . Stats.gcWallSeconds)
-    , ("cpu_seconds"              , show . Stats.cpuSeconds)
-    , ("wall_seconds"             , show . Stats.wallSeconds)
-    , ("par_tot_bytes_copied"     , show . gcParTotBytesCopied)
-    , ("par_avg_bytes_copied"     , show . gcParTotBytesCopied)
-    , ("par_max_bytes_copied"     , show . Stats.parMaxBytesCopied)
-    ]
-
-------------------------------------------------------------------------
-
--- Utilities for working with accept headers
-
--- | Parse the HTTP accept string to determine supported content types.
-parseHttpAccept :: S.ByteString -> [S.ByteString]
-parseHttpAccept = List.map fst
-                . List.sortBy (rcompare `on` snd)
-                . List.map grabQ
-                . S.split 44 -- comma
-  where
-    rcompare :: Double -> Double -> Ordering
-    rcompare = flip compare
-    grabQ s =
-        let (s', q) = breakDiscard 59 s -- semicolon
-            (_, q') = breakDiscard 61 q -- equals sign
-         in (trimWhite s', readQ $ trimWhite q')
-    readQ s = case reads $ S8.unpack s of
-                (x, _):_ -> x
-                _ -> 1.0
-    trimWhite = S.dropWhile (== 32) -- space
-
-breakDiscard :: Word8 -> S.ByteString -> (S.ByteString, S.ByteString)
-breakDiscard w s =
-    let (x, y) = S.break (== w) s
-     in (x, S.drop 1 y)
-
-------------------------------------------------------------------------
--- Utilities for working with timestamps
-
--- | Return the number of milliseconds since epoch.
-getTimeMillis :: IO Double
-getTimeMillis = (realToFrac . (* 1000)) `fmap` getPOSIXTime
-
--- | Helper to work around rename in GHC.Stats in base-4.6.
-gcParTotBytesCopied :: Stats.GCStats -> Int64
-#if MIN_VERSION_base(4,6,0)
-gcParTotBytesCopied = Stats.parTotBytesCopied
-#else
-gcParTotBytesCopied = Stats.parAvgBytesCopied
-#endif
diff --git a/System/Remote/Counter.hs b/System/Remote/Counter.hs
--- a/System/Remote/Counter.hs
+++ b/System/Remote/Counter.hs
@@ -1,28 +1,18 @@
-{-# LANGUAGE BangPatterns #-}
+{-# OPTIONS_HADDOCK not-home #-}
+
 -- | This module defines a type for mutable, integer-valued counters.
 -- Counters are non-negative, monotonically increasing values and can
 -- be used to track e.g. the number of requests served since program
 -- start.  All operations on counters are thread-safe.
+--
+-- N.B. This module exists to maintain backwards compatibility with
+-- older versions of this library. New code should use the
+-- @System.Metrics.Counter@ module from the ekg-core package instead.
 module System.Remote.Counter
     (
-      Counter
-    , inc
-    , add
+      Counter.Counter
+    , Counter.inc
+    , Counter.add
     ) where
 
-import Data.IORef (atomicModifyIORef)
-import Prelude hiding (subtract)
-
-import System.Remote.Counter.Internal
-
--- | Increase the counter by one.
-inc :: Counter -> IO ()
-inc (C ref) = do
-    !_ <- atomicModifyIORef ref $ \ n -> let n' = n + 1 in (n', n')
-    return ()
-
--- | Increase the counter by the given amount.
-add :: Counter -> Int -> IO ()
-add (C ref) i = do
-    !_ <- atomicModifyIORef ref $ \ n -> let n' = n + i in (n', n')
-    return ()
+import qualified System.Metrics.Counter as Counter
diff --git a/System/Remote/Counter/Internal.hs b/System/Remote/Counter/Internal.hs
deleted file mode 100644
--- a/System/Remote/Counter/Internal.hs
+++ /dev/null
@@ -1,20 +0,0 @@
-{-# OPTIONS_HADDOCK not-home #-}
-module System.Remote.Counter.Internal
-    (
-      Counter(..)
-    , new
-    , read
-    ) where
-
-import Data.IORef (IORef, newIORef, readIORef)
-import Prelude hiding (read)
-
--- | A mutable, integer-valued counter.
-newtype Counter = C { unC :: IORef Int }
-
--- | Create a new, zero initialized, counter.
-new :: IO Counter
-new = C `fmap` newIORef 0
-
-read :: Counter -> IO Int
-read = readIORef . unC
diff --git a/System/Remote/Gauge.hs b/System/Remote/Gauge.hs
--- a/System/Remote/Gauge.hs
+++ b/System/Remote/Gauge.hs
@@ -1,55 +1,21 @@
-{-# LANGUAGE BangPatterns #-}
+{-# OPTIONS_HADDOCK not-home #-}
+
 -- | This module defines a type for mutable, integer-valued gauges.
 -- Gauges are variable values and can be used to track e.g. the
 -- current number of concurrent connections. All operations on gauges
 -- are thread-safe.
+--
+-- N.B. This module exists to maintain backwards compatibility with
+-- older versions of this library. New code should use the
+-- @System.Metrics.Gauge@ module from the ekg-core package instead.
 module System.Remote.Gauge
     (
-      Gauge
-    , inc
-    , dec
-    , add
-    , subtract
-    , set
-    , modify
+      Gauge.Gauge
+    , Gauge.inc
+    , Gauge.dec
+    , Gauge.add
+    , Gauge.subtract
+    , Gauge.set
     ) where
 
-import Data.IORef (atomicModifyIORef)
-import Prelude hiding (subtract)
-
-import System.Remote.Gauge.Internal
-
--- | Increase the gauge by one.
-inc :: Gauge -> IO ()
-inc (C ref) = do
-    !_ <- atomicModifyIORef ref $ \ n -> let n' = n + 1 in (n', n')
-    return ()
-
--- | Decrease the gauge by one.
-dec :: Gauge -> IO ()
-dec (C ref) = do
-    !_ <- atomicModifyIORef ref $ \ n -> let n' = n - 1 in (n', n')
-    return ()
-
--- | Increase the gauge by the given amount.
-add :: Gauge -> Int -> IO ()
-add (C ref) i = do
-    !_ <- atomicModifyIORef ref $ \ n -> let n' = n + i in (n', n')
-    return ()
-
--- | Decrease the gauge by the given amount.
-subtract :: Gauge -> Int -> IO ()
-subtract (C ref) i = do
-    !_ <- atomicModifyIORef ref $ \ n -> let n' = n - i in (n', n')
-    return ()
-
--- | Set the gauge to the given value.
-set :: Gauge -> Int -> IO ()
-set (C ref) !i = atomicModifyIORef ref $ \ _ -> (i, ())
-
--- | Set the gauge to the result of applying the given function to the
--- value.
-modify :: (Int -> Int) -> Gauge -> IO ()
-modify f (C ref) = do
-    !_ <- atomicModifyIORef ref $ \ i -> let i' = f i in (i', i')
-    return ()
+import qualified System.Metrics.Gauge as Gauge
diff --git a/System/Remote/Gauge/Internal.hs b/System/Remote/Gauge/Internal.hs
deleted file mode 100644
--- a/System/Remote/Gauge/Internal.hs
+++ /dev/null
@@ -1,20 +0,0 @@
-{-# OPTIONS_HADDOCK not-home #-}
-module System.Remote.Gauge.Internal
-    (
-      Gauge(..)
-    , new
-    , read
-    ) where
-
-import Data.IORef (IORef, newIORef, readIORef)
-import Prelude hiding (read)
-
--- | A mutable, integer-valued gauge.
-newtype Gauge = C { unC :: IORef Int }
-
--- | Create a new, zero initialized, gauge.
-new :: IO Gauge
-new = C `fmap` newIORef 0
-
-read :: Gauge -> IO Int
-read = readIORef . unC
diff --git a/System/Remote/Json.hs b/System/Remote/Json.hs
new file mode 100644
--- /dev/null
+++ b/System/Remote/Json.hs
@@ -0,0 +1,108 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module System.Remote.Json
+    (
+      encodeAll
+    , encodeOne
+    ) where
+
+import qualified Data.Aeson.Encode as A
+import qualified Data.Aeson.Types as A
+import qualified Data.ByteString.Lazy as L
+import qualified Data.HashMap.Strict as M
+import Data.Int (Int64)
+import qualified Data.Text as T
+import Prelude hiding (read)
+
+import System.Metrics
+import qualified System.Metrics.Distribution as Distribution
+
+------------------------------------------------------------------------
+-- * JSON serialization
+
+data MetricType =
+      CounterType
+    | GaugeType
+    | LabelType
+    | DistributionType
+
+metricType :: MetricType -> T.Text
+metricType CounterType      = "c"
+metricType GaugeType        = "g"
+metricType LabelType        = "l"
+metricType DistributionType = "d"
+
+-- | Encode metrics as nested JSON objects. Each "." in the metric
+-- name introduces a new level of nesting. For example, the metrics
+-- @[("foo.bar", 10), ("foo.baz", "label")]@ are encoded as
+--
+-- > {
+-- >   "foo": {
+-- >     "bar": {
+-- >       "type:", "c",
+-- >       "val": 10
+-- >     },
+-- >     "baz": {
+-- >       "type": "l",
+-- >       "val": "label"
+-- >     }
+-- >   }
+-- > }
+--
+encodeAll :: Sample -> L.ByteString
+encodeAll metrics =
+    A.encode $ buildOne metrics $ A.emptyObject
+  where
+    buildOne :: M.HashMap T.Text Value -> A.Value -> A.Value
+    buildOne m o = M.foldlWithKey' build o m
+
+    build :: A.Value -> T.Text -> Value -> A.Value
+    build m name val = go m (T.splitOn "." name) val
+
+    go :: A.Value -> [T.Text] -> Value -> A.Value
+    go (A.Object m) [str] val      = A.Object $ M.insert str metric m
+      where metric = buildOneM val
+    go (A.Object m) (str:rest) val = case M.lookup str m of
+        Nothing -> A.Object $ M.insert str (go A.emptyObject rest val) m
+        Just m' -> A.Object $ M.insert str (go m' rest val) m
+    go v _ _                        = typeMismatch "Object" v
+
+buildOneM :: Value -> A.Value
+buildOneM (Counter n)      = goOne n CounterType
+buildOneM (Gauge n)        = goOne n GaugeType
+buildOneM (Label l)        = goOne l LabelType
+buildOneM (Distribution l) = goDistribution l
+
+goDistribution :: Distribution.Stats -> A.Value
+goDistribution stats = A.object [
+    ("mean", A.toJSON $! Distribution.mean stats),
+    ("variance", A.toJSON $! Distribution.variance stats),
+    ("count", A.toJSON $! Distribution.count stats),
+    ("sum", A.toJSON $! Distribution.sum stats),
+    ("min", A.toJSON $! Distribution.min stats),
+    ("max", A.toJSON $! Distribution.max stats),
+    ("type", A.toJSON (metricType DistributionType))]
+
+goOne :: A.ToJSON a => a -> MetricType -> A.Value
+goOne val ty = A.object [
+    ("val", A.toJSON val), ("type", A.toJSON (metricType ty))]
+{-# SPECIALIZE goOne :: Int64 -> MetricType -> A.Value #-}
+{-# SPECIALIZE goOne :: T.Text -> MetricType -> A.Value #-}
+
+encodeOne :: Value -> L.ByteString
+encodeOne = A.encode . buildOneM
+
+typeMismatch :: String   -- ^ The expected type
+             -> A.Value  -- ^ The actual value encountered
+             -> a
+typeMismatch expected actual =
+    error $ "when expecting a " ++ expected ++ ", encountered " ++ name ++
+    " instead"
+  where
+    name = case actual of
+        A.Object _ -> "Object"
+        A.Array _  -> "Array"
+        A.String _ -> "String"
+        A.Number _ -> "Number"
+        A.Bool _   -> "Boolean"
+        A.Null     -> "Null"
diff --git a/System/Remote/Label.hs b/System/Remote/Label.hs
--- a/System/Remote/Label.hs
+++ b/System/Remote/Label.hs
@@ -1,27 +1,18 @@
-{-# LANGUAGE BangPatterns #-}
+{-# OPTIONS_HADDOCK not-home #-}
+
 -- | This module defines a type for mutable, string-valued labels.
 -- Labels are variable values and can be used to track e.g. the
 -- command line arguments or other free-form values. All operations on
 -- labels are thread-safe.
+--
+-- N.B. This module exists to maintain backwards compatibility with
+-- older versions of this library. New code should use the
+-- @System.Metrics.Label@ module from the ekg-core package instead.
 module System.Remote.Label
     (
-      Label
-    , set
-    , modify
+      Label.Label
+    , Label.set
+    , Label.modify
     ) where
 
-import Data.IORef (atomicModifyIORef)
-import qualified Data.Text as T
-
-import System.Remote.Label.Internal
-
--- | Set the label to the given value.
-set :: Label -> T.Text -> IO ()
-set (C ref) !i = atomicModifyIORef ref $ \ _ -> (i, ())
-
--- | Set the label to the result of applying the given function to the
--- value.
-modify :: (T.Text -> T.Text) -> Label -> IO ()
-modify f (C ref) = do
-    !_ <- atomicModifyIORef ref $ \ i -> let i' = f i in (i', i')
-    return ()
+import qualified System.Metrics.Label as Label
diff --git a/System/Remote/Label/Internal.hs b/System/Remote/Label/Internal.hs
deleted file mode 100644
--- a/System/Remote/Label/Internal.hs
+++ /dev/null
@@ -1,21 +0,0 @@
-{-# OPTIONS_HADDOCK not-home #-}
-module System.Remote.Label.Internal
-    (
-      Label(..)
-    , new
-    , read
-    ) where
-
-import Data.IORef (IORef, newIORef, readIORef)
-import qualified Data.Text as T
-import Prelude hiding (read)
-
--- | A mutable, text-valued label.
-newtype Label = C { unC :: IORef T.Text }
-
--- | Create a new empty label.
-new :: IO Label
-new = C `fmap` newIORef T.empty
-
-read :: Label -> IO T.Text
-read = readIORef . unC
diff --git a/System/Remote/Monitoring.hs b/System/Remote/Monitoring.hs
--- a/System/Remote/Monitoring.hs
+++ b/System/Remote/Monitoring.hs
@@ -1,4 +1,4 @@
-{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 -- | This module provides remote monitoring of a running process over
 -- HTTP.  It can be used to run an HTTP server that provides both a
@@ -31,30 +31,37 @@
       -- * The monitoring server
       Server
     , serverThreadId
+    , serverMetricStore
     , forkServer
+    , forkServerWith
 
-      -- * User-defined counters, gauges, and labels
+      -- * Defining metrics
       -- $userdefined
     , getCounter
     , getGauge
     , getLabel
+    , getDistribution
     ) where
 
 import Control.Concurrent (ThreadId, forkIO)
 import qualified Data.ByteString as S
-import qualified Data.HashMap.Strict as M
-import Data.IORef (newIORef)
+import Data.Int (Int64)
+import qualified Data.Text as T
+import Data.Time.Clock.POSIX (getPOSIXTime)
 import Prelude hiding (read)
 
-import System.Remote.Common
-
+import qualified System.Metrics as Metrics
+import qualified System.Metrics.Counter as Counter
+import qualified System.Metrics.Distribution as Distribution
+import qualified System.Metrics.Gauge as Gauge
+import qualified System.Metrics.Label as Label
 import System.Remote.Snap
 
 -- $configuration
 --
--- To use this module you must first enable GC statistics collection
--- in the run-time system.  To enable GC statistics collection, either
--- run your program with
+-- To make full use out of this this module you must first enable GC
+-- statistics collection in the run-time system. To enable GC
+-- statistics collection, either run your program with
 --
 -- > +RTS -T
 --
@@ -73,118 +80,79 @@
 
 -- $api
 -- To use the machine-readable REST API, send an HTTP GET request to
--- the host and port passed to 'forkServer'.  The following resources
--- (i.e. URLs) are available:
---
--- [\/] JSON object containing all counters, gauges and labels.
--- Counters, gauges, and labels are stored as nested objects under the
--- @counters@, @gauges@, and @labels@ attributes, respectively.
--- Content types: \"text\/html\" (default), \"application\/json\"
---
--- [\/combined] Flattened JSON object containing all counters, gauges,
--- and labels.  Content types: \"application\/json\"
---
--- [\/counters] JSON object containing all counters.  Content types:
--- \"application\/json\"
---
--- [\/counters/\<counter name\>] Value of a single counter, as a
--- string.  The name should be UTF-8 encoded.  Content types:
--- \"text\/plain\"
+-- the host and port passed to 'forkServer'.
 --
--- [\/gauges] JSON object containing all gauges.  Content types:
--- \"application\/json\"
+-- The API is versioned to allow for API evolution. This document is
+-- for version 1. To ensure you're using this version, append @?v=1@
+-- to your resource URLs. Omitting the version number will give you
+-- the latest version of the API.
 --
--- [\/gauges/\<gauge name\>] Value of a single gauge, as a string.
--- The name should be UTF-8 encoded.  Content types: \"text\/plain\"
+-- The following resources (i.e. URLs) are available:
 --
--- [\/labels] JSON object containing all labels.  Content types:
+-- [\/] JSON object containing all metrics. Metrics are stored as
+-- nested objects, with one new object layer per \".\" in the metric
+-- name (see example below.) Content types: \"text\/html\" (default),
 -- \"application\/json\"
 --
--- [\/labels/\<label name\>] Value of a single label, as a string.
--- The name should be UTF-8 encoded.  Content types: \"text\/plain\"
---
--- Counters, gauges and labels are stored as attributes of the
--- returned JSON objects, one attribute per counter, gauge or label.
--- In addition to user-defined counters, gauges, and labels, the below
--- built-in counters and gauges are also returned.  Furthermore, the
--- top-level JSON object of any resource contains the
--- @server_timestamp_millis@ attribute, which indicates the server
--- time, in milliseconds, when the sample was taken.
---
--- Built-in counters:
---
--- [@bytes_allocated@] Total number of bytes allocated
---
--- [@num_gcs@] Number of garbage collections performed
---
--- [@num_bytes_usage_samples@] Number of byte usage samples taken
---
--- [@cumulative_bytes_used@] Sum of all byte usage samples, can be
--- used with @numByteUsageSamples@ to calculate averages with
--- arbitrary weighting (if you are sampling this record multiple
--- times).
---
--- [@bytes_copied@] Number of bytes copied during GC
---
--- [@mutator_cpu_seconds@] CPU time spent running mutator threads.
--- This does not include any profiling overhead or initialization.
---
--- [@mutator_wall_seconds@] Wall clock time spent running mutator
--- threads.  This does not include initialization.
---
--- [@gc_cpu_seconds@] CPU time spent running GC
---
--- [@gc_wall_seconds@] Wall clock time spent running GC
---
--- [@cpu_seconds@] Total CPU time elapsed since program start
+-- [\/\<namespace\>/\<metric\>] JSON object for a single metric. The
+-- metric name is created by converting all \"\/\" to \".\". Example:
+-- \"\/foo\/bar\" corresponds to the metric \"foo.bar\". Content
+-- types: \"application\/json\"
 --
--- [@wall_seconds@] Total wall clock time elapsed since start
+-- Each metric is returned as an object containing a @type@ field.  Available types
+-- are:
 --
--- Built-in gauges:
+--  * \"c\" - 'Counter.Counter'
 --
--- [@max_bytes_used@] Maximum number of live bytes seen so far
+--  * \"g\" - 'Gauge.Gauge'
 --
--- [@current_bytes_used@] Current number of live bytes
+--  * \"l\" - 'Label.Label'
 --
--- [@current_bytes_slop@] Current number of bytes lost to slop
+--  * \"d\" - 'Distribution.Distribution'
 --
--- [@max_bytes_slop@] Maximum number of bytes lost to slop at any one time so far
+-- In addition to the @type@ field, there are metric specific fields:
 --
--- [@peak_megabytes_allocated@] Maximum number of megabytes allocated
+--  * Counters, gauges, and labels: the @val@ field contains the
+--    actual value (i.e. an integer or a string).
 --
--- [@par_tot_bytes_copied@] Number of bytes copied during GC, minus
--- space held by mutable lists held by the capabilities.  Can be used
--- with 'parMaxBytesCopied' to determine how well parallel GC utilized
--- all cores.
+--  * Distributions: the @mean@, @variance@, @count@, @sum@, @min@,
+--    and @max@ fields contain their statistical equivalents.
 --
--- [@par_avg_bytes_copied@] Deprecated alias for
--- @par_tot_bytes_copied@.
+-- Example of a response containing the metrics \"myapp.visitors\" and
+-- \"myapp.args\":
 --
--- [@par_max_bytes_copied@] Sum of number of bytes copied each GC by
--- the most active GC thread each GC. The ratio of
--- @par_tot_bytes_copied@ divided by @par_max_bytes_copied@ approaches
--- 1 for a maximally sequential run and approaches the number of
--- threads (set by the RTS flag @-N@) for a maximally parallel run.
+-- > {
+-- >   "myapp": {
+-- >     "visitors": {
+-- >       "val": 10,
+-- >       "type": "c"
+-- >     },
+-- >     "args": {
+-- >       "val": "--a-flag",
+-- >       "type": "l"
+-- >     }
+-- >   }
+-- > }
 
 -- $userdefined
--- The monitoring server can store and serve user-defined,
--- integer-valued counters and gauges, and string-valued labels.  A
+-- The monitoring server can store and serve integer-valued counters
+-- and gauges, string-valued labels, and statistical distributions. A
 -- counter is a monotonically increasing value (e.g. TCP connections
--- established since program start.) A gauge is a variable value
--- (e.g. the current number of concurrent connections.) A label is a
+-- established since program start.) A gauge is a variable value (e.g.
+-- the current number of concurrent connections.) A label is a
 -- free-form string value (e.g. exporting the command line arguments
--- or host name.)  Each counter, gauge, and label is associated with a
--- name, which is used when it is displayed in the UI or returned in a
--- JSON object.
+-- or host name.) A distribution is a statistic summary of events
+-- (e.g. processing time per request.) Each metric is associated with
+-- a name, which is used when it is displayed in the UI or returned in
+-- a JSON object.
 --
--- Even though it's technically possible to have a counter and a gauge
--- with the same name, associated with the same server, it's not
--- recommended as it might make it harder for clients to distinguish
--- the two.
+-- Metrics share the same namespace so it's not possible to create
+-- e.g. a counter and a gauge with the same. Attempting to do so will
+-- result in an 'error'.
 --
 -- To create and use a counter, simply call 'getCounter' to create it
--- and then call e.g. 'System.Remote.Counter.inc' or
--- 'System.Remote.Counter.add' to modify its value.  Example:
+-- and then call e.g. 'Counter.inc' or 'Counter.add' to modify its
+-- value. Example:
 --
 -- > main = do
 -- >     handle <- forkServer "localhost" 8000
@@ -195,31 +163,104 @@
 -- >     loop
 --
 -- To create a gauge, use 'getGauge' instead of 'getCounter' and then
--- call e.g. 'System.Remote.Gauge.set' or
--- 'System.Remote.Gauge.modify'.  Similar for labels.
+-- call e.g. 'System.Remote.Gauge.set'. Similar for the other metric
+-- types.
+--
+-- It's also possible to register metrics directly using the
+-- @System.Metrics@ module in the ekg-core package. This gives you a
+-- bit more control over how metric values are retrieved.
 
 ------------------------------------------------------------------------
 -- * The monitoring server
 
--- | The thread ID of the server.  You can kill the server by killing
--- this thread (i.e. by throwing it an asynchronous exception.)
-serverThreadId :: Server -> ThreadId
-serverThreadId = threadId
+-- | A handle that can be used to control the monitoring server.
+-- Created by 'forkServer'.
+data Server = Server {
+      -- | The thread ID of the server. You can kill the server by
+      -- killing this thread (i.e. by throwing it an asynchronous
+      -- exception.)
+      serverThreadId :: {-# UNPACK #-} !ThreadId
 
+      -- | The metric store associated with the server. If you want to
+      -- add metric to the default store created by 'forkServer' you
+      -- need to use this function to retrieve it.
+    , serverMetricStore :: {-# UNPACK #-} !Metrics.Store
+    }
+
+-- | Like 'forkServerWith', but creates a default metric store with
+-- some predefined metrics. The predefined metrics are those given in
+-- 'System.Metrics.registerGcMetrics'.
+forkServer :: S.ByteString  -- ^ Host to listen on (e.g. \"localhost\")
+           -> Int           -- ^ Port to listen on (e.g. 8000)
+           -> IO Server
+forkServer host port = do
+    store <- Metrics.newStore
+    forkServerWith store host port
+
 -- | Start an HTTP server in a new thread.  The server replies to GET
 -- requests to the given host and port.  The host argument can be
 -- either a numeric network address (dotted quad for IPv4,
 -- colon-separated hex for IPv6) or a hostname (e.g. \"localhost\".)
 -- The client can control the Content-Type used in responses by
 -- setting the Accept header.  At the moment three content types are
--- available: \"application\/json\", \"text\/html\", and
--- \"text\/plain\".
-forkServer :: S.ByteString  -- ^ Host to listen on (e.g. \"localhost\")
-           -> Int           -- ^ Port to listen on (e.g. 8000)
-           -> IO Server
-forkServer host port = do
-    counters <- newIORef M.empty
-    gauges <- newIORef M.empty
-    labels <- newIORef M.empty
-    tid <- forkIO $ startServer counters gauges labels host port
-    return $! Server tid counters gauges labels
+-- available: \"application\/json\" and \"text\/html\".
+--
+-- Registers the following counter, used by the UI:
+--
+-- [@ekg.server_time_ms@] The server time when the sample was taken,
+-- in milliseconds.
+--
+-- Note that this function, unlike 'forkServer', doesn't register any
+-- other predefined metrics. This allows other libraries to create and
+-- provide a metric store for use with this library. If the metric
+-- store isn't created by you and the creator doesn't register the
+-- metrics registered by 'forkServer', you might want to register them
+-- yourself.
+forkServerWith :: Metrics.Store  -- ^ Metric store
+               -> S.ByteString   -- ^ Host to listen on (e.g. \"localhost\")
+               -> Int            -- ^ Port to listen on (e.g. 8000)
+               -> IO Server
+forkServerWith store host port = do
+    Metrics.registerGcMetrics store
+    Metrics.registerCounter "ekg.server_timestamp_ms" getTimeMs store
+    tid <- forkIO $ startServer store host port
+    return $! Server tid store
+  where
+    getTimeMs :: IO Int64
+    getTimeMs = (round . (* 1000)) `fmap` getPOSIXTime
+
+------------------------------------------------------------------------
+-- * Defining metrics
+
+-- | Return a new, zero-initialized counter associated with the given
+-- name and server. Multiple calls to 'getCounter' with the same
+-- arguments will result in an 'error'.
+getCounter :: T.Text  -- ^ Counter name
+           -> Server  -- ^ Server that will serve the counter
+           -> IO Counter.Counter
+getCounter name server = Metrics.createCounter name (serverMetricStore server)
+
+-- | Return a new, zero-initialized gauge associated with the given
+-- name and server. Multiple calls to 'getGauge' with the same
+-- arguments will result in an 'error'.
+getGauge :: T.Text  -- ^ Gauge name
+         -> Server  -- ^ Server that will serve the gauge
+         -> IO Gauge.Gauge
+getGauge name server = Metrics.createGauge name (serverMetricStore server)
+
+-- | Return a new, empty label associated with the given name and
+-- server. Multiple calls to 'getLabel' with the same arguments will
+-- result in an 'error'.
+getLabel :: T.Text  -- ^ Label name
+         -> Server  -- ^ Server that will serve the label
+         -> IO Label.Label
+getLabel name server = Metrics.createLabel name (serverMetricStore server)
+
+-- | Return a new distribution associated with the given name and
+-- server. Multiple calls to 'getDistribution' with the same arguments
+-- will result in an 'error'.
+getDistribution :: T.Text  -- ^ Distribution name
+                -> Server  -- ^ Server that will serve the distribution
+                -> IO Distribution.Distribution
+getDistribution name server =
+    Metrics.createDistribution name (serverMetricStore server)
diff --git a/System/Remote/Snap.hs b/System/Remote/Snap.hs
--- a/System/Remote/Snap.hs
+++ b/System/Remote/Snap.hs
@@ -6,32 +6,29 @@
 
 import Control.Applicative ((<$>), (<|>))
 import Control.Exception (throwIO)
-import Control.Monad (join, unless)
 import Control.Monad.IO.Class (liftIO)
-import qualified Data.Aeson.Types as A
 import qualified Data.ByteString as S
 import qualified Data.ByteString.Char8 as S8
+import Data.Function (on)
 import qualified Data.HashMap.Strict as M
-import Data.IORef (IORef)
 import qualified Data.List as List
-import qualified Data.Map as Map
-import Data.Maybe (listToMaybe)
-import qualified Data.Text as T
 import qualified Data.Text.Encoding as T
+import Data.Word (Word8)
 import Network.Socket (NameInfoFlag(NI_NUMERICHOST), addrAddress, getAddrInfo,
                        getNameInfo)
 import Paths_ekg (getDataDir)
 import Prelude hiding (read)
 import Snap.Core (MonadSnap, Request, Snap, finishWith, getHeaders, getRequest,
-                  getResponse, method, Method(GET), modifyResponse, pass, route,
-                  rqParams, rqPathInfo, setContentType, setResponseStatus,
-                  writeBS, writeLBS)
+                  getResponse, method, Method(GET), modifyResponse, pass,
+                  rqPathInfo, setContentType, setResponseStatus,
+                  writeLBS)
 import Snap.Http.Server (httpServe)
 import qualified Snap.Http.Server.Config as Config
 import Snap.Util.FileServe (serveDirectory)
 import System.FilePath ((</>))
 
-import System.Remote.Common
+import System.Metrics
+import System.Remote.Json
 
 ------------------------------------------------------------------------
 
@@ -51,11 +48,11 @@
     unsupportedAddressError = throwIO $
         userError $ "unsupported address: " ++ S8.unpack host
 
-startServer :: IORef Counters -> IORef Gauges -> IORef Labels
+startServer :: Store
             -> S.ByteString  -- ^ Host to listen on (e.g. \"localhost\")
             -> Int           -- ^ Port to listen on (e.g. 8000)
             -> IO ()
-startServer counters gauges labels host port = do
+startServer store host port = do
     -- Snap doesn't allow for non-numeric host names in
     -- 'Snap.setBind'. We work around that limitation by converting a
     -- possible non-numeric host name to a numeric address.
@@ -67,31 +64,17 @@
                Config.setHostname host $
                Config.setBind numericHost $
                Config.defaultConfig
-    httpServe conf (monitor counters gauges labels)
+    httpServe conf (monitor store)
 
 -- | A handler that can be installed into an existing Snap application.
-monitor :: IORef Counters -> IORef Gauges -> IORef Labels -> Snap ()
-monitor counters gauges labels = do
+monitor :: Store -> Snap ()
+monitor store = do
     dataDir <- liftIO getDataDir
-    route [
-          ("", method GET (format "application/json"
-                           (serveAll counters gauges labels)))
-        , ("combined", method GET (format "application/json"
-                                   (serveCombined counters gauges labels)))
-        , ("counters", method GET (format "application/json"
-                                   (serveMany counters)))
-        , ("counters/:name", method GET (format "text/plain"
-                                         (serveOne counters)))
-        , ("gauges", method GET (format "application/json"
-                                 (serveMany gauges)))
-        , ("gauges/:name", method GET (format "text/plain"
-                                       (serveOne gauges)))
-        , ("labels", method GET (format "application/json"
-                                 (serveMany labels)))
-        , ("labels/:name", method GET (format "text/plain"
-                                       (serveOne labels)))
-        ]
+    (jsonHandler $ serve store)
         <|> serveDirectory (dataDir </> "assets")
+  where
+    jsonHandler = wrapHandler "application/json"
+    wrapHandler fmt handler = method GET $ format fmt $ handler
 
 -- | The Accept header of the request.
 acceptHeader :: Request -> Maybe S.ByteString
@@ -107,50 +90,55 @@
         Just hdr | hdr == fmt -> action
         _ -> pass
 
--- | Serve a collection of counters or gauges, as a JSON object.
-serveMany :: (Ref r t, A.ToJSON t) => IORef (M.HashMap T.Text r) -> Snap ()
-serveMany mapRef = do
-    modifyResponse $ setContentType "application/json"
-    bs <- liftIO $ buildMany mapRef
-    writeLBS bs
-{-# INLINABLE serveMany #-}
-
 -- | Serve all counter, gauges and labels, built-in or not, as a
 -- nested JSON object.
-serveAll :: IORef Counters -> IORef Gauges -> IORef Labels -> Snap ()
-serveAll counters gauges labels = do
+serve :: MonadSnap m => Store -> m ()
+serve store = do
     req <- getRequest
-    -- Workaround: Snap still matches requests to /foo to this handler
-    -- if the Accept header is "application/json", even though such
-    -- requests ought to go to the 'serveOne' handler.
-    unless (S.null $ rqPathInfo req) pass
     modifyResponse $ setContentType "application/json"
-    bs <- liftIO $ buildAll counters gauges labels
-    writeLBS bs
+    if S.null (rqPathInfo req)
+        then serveAll
+        else serveOne (rqPathInfo req)
+  where
+    serveAll = do
+        metrics <- liftIO $ sampleAll store
+        writeLBS $ encodeAll metrics
+    serveOne pathInfo = do
+        let segments  = S8.split '/' pathInfo
+            nameBytes = S8.intercalate "." segments
+        case T.decodeUtf8' nameBytes of
+            Left _ -> do
+                modifyResponse $ setResponseStatus 400 "Bad Request"
+                r <- getResponse
+                finishWith r
+            Right name -> do
+                metrics <- liftIO $ sampleAll store
+                case M.lookup name metrics of
+                    Nothing -> pass
+                    Just metric -> writeLBS $ encodeOne metric
 
--- | Serve all counters and gauges, built-in or not, as a flattened
--- JSON object.
-serveCombined :: IORef Counters -> IORef Gauges -> IORef Labels -> Snap ()
-serveCombined counters gauges labels = do
-    modifyResponse $ setContentType "application/json"
-    bs <- liftIO $ buildCombined counters gauges labels
-    writeLBS bs
+------------------------------------------------------------------------
+-- Utilities for working with accept headers
 
--- | Serve a single counter, as plain text.
-serveOne :: (Ref r t, Show t) => IORef (M.HashMap T.Text r) -> Snap ()
-serveOne refs = do
-    modifyResponse $ setContentType "text/plain"
-    req <- getRequest
-    let mname = T.decodeUtf8 <$> join
-                (listToMaybe <$> Map.lookup "name" (rqParams req))
-    case mname of
-        Nothing -> pass
-        Just name -> do
-            mbs <- liftIO $ buildOne refs name
-            case mbs of
-                Just bs -> writeBS bs
-                Nothing -> do
-                    modifyResponse $ setResponseStatus 404 "Not Found"
-                    r <- getResponse
-                    finishWith r
-{-# INLINABLE serveOne #-}
+-- | Parse the HTTP accept string to determine supported content types.
+parseHttpAccept :: S.ByteString -> [S.ByteString]
+parseHttpAccept = List.map fst
+                . List.sortBy (rcompare `on` snd)
+                . List.map grabQ
+                . S.split 44 -- comma
+  where
+    rcompare :: Double -> Double -> Ordering
+    rcompare = flip compare
+    grabQ s =
+        let (s', q) = breakDiscard 59 s -- semicolon
+            (_, q') = breakDiscard 61 q -- equals sign
+         in (trimWhite s', readQ $ trimWhite q')
+    readQ s = case reads $ S8.unpack s of
+                (x, _):_ -> x
+                _ -> 1.0
+    trimWhite = S.dropWhile (== 32) -- space
+
+breakDiscard :: Word8 -> S.ByteString -> (S.ByteString, S.ByteString)
+breakDiscard w s =
+    let (x, y) = S.break (== w) s
+    in (x, S.drop 1 y)
diff --git a/assets/bootstrap-1.4.0.min.css b/assets/bootstrap-1.4.0.min.css
--- a/assets/bootstrap-1.4.0.min.css
+++ b/assets/bootstrap-1.4.0.min.css
@@ -59,22 +59,22 @@
 .span22{width:1300px;}
 .span23{width:1360px;}
 .span24{width:1420px;}
-.row>.offset1{margin-left:80px;}
-.row>.offset2{margin-left:140px;}
-.row>.offset3{margin-left:200px;}
-.row>.offset4{margin-left:260px;}
-.row>.offset5{margin-left:320px;}
-.row>.offset6{margin-left:380px;}
-.row>.offset7{margin-left:440px;}
-.row>.offset8{margin-left:500px;}
-.row>.offset9{margin-left:560px;}
-.row>.offset10{margin-left:620px;}
-.row>.offset11{margin-left:680px;}
-.row>.offset12{margin-left:740px;}
+.row >.offset1{margin-left:80px;}
+.row >.offset2{margin-left:140px;}
+.row >.offset3{margin-left:200px;}
+.row >.offset4{margin-left:260px;}
+.row >.offset5{margin-left:320px;}
+.row >.offset6{margin-left:380px;}
+.row >.offset7{margin-left:440px;}
+.row >.offset8{margin-left:500px;}
+.row >.offset9{margin-left:560px;}
+.row >.offset10{margin-left:620px;}
+.row >.offset11{margin-left:680px;}
+.row >.offset12{margin-left:740px;}
 .span-one-third{width:300px;}
 .span-two-thirds{width:620px;}
-.row>.offset-one-third{margin-left:340px;}
-.row>.offset-two-thirds{margin-left:660px;}
+.offset-one-third{margin-left:340px;}
+.offset-two-thirds{margin-left:660px;}
 p{font-size:13px;font-weight:normal;line-height:18px;margin-bottom:9px;}p small{font-size:11px;color:#bfbfbf;}
 h1,h2,h3,h4,h5,h6{font-weight:bold;color:#404040;}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{color:#bfbfbf;}
 h1{margin-bottom:18px;font-size:30px;line-height:36px;}h1 small{font-size:18px;}
@@ -122,7 +122,7 @@
 .uneditable-input{background-color:#ffffff;display:block;border-color:#eee;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);cursor:not-allowed;}
 :-moz-placeholder{color:#bfbfbf;}
 ::-webkit-input-placeholder{color:#bfbfbf;}
-input,textarea{-webkit-transition:border linear 0.2s,box-shadow linear 0.2s;-moz-transition:border linear 0.2s,box-shadow linear 0.2s;-ms-transition:border linear 0.2s,box-shadow linear 0.2s;-o-transition:border linear 0.2s,box-shadow linear 0.2s;transition:border linear 0.2s,box-shadow linear 0.2s;-webkit-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);-moz-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);}
+input,textarea{-webkit-transform-style:preserve-3d;-webkit-transition:border linear 0.2s,box-shadow linear 0.2s;-moz-transition:border linear 0.2s,box-shadow linear 0.2s;-ms-transition:border linear 0.2s,box-shadow linear 0.2s;-o-transition:border linear 0.2s,box-shadow linear 0.2s;transition:border linear 0.2s,box-shadow linear 0.2s;-webkit-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);-moz-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1);}
 input:focus,textarea:focus{outline:0;border-color:rgba(82, 168, 236, 0.8);-webkit-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1),0 0 8px rgba(82, 168, 236, 0.6);-moz-box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1),0 0 8px rgba(82, 168, 236, 0.6);box-shadow:inset 0 1px 3px rgba(0, 0, 0, 0.1),0 0 8px rgba(82, 168, 236, 0.6);}
 input[type=file]:focus,input[type=checkbox]:focus,select:focus{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;outline:1px dotted #666;}
 form .clearfix.error>label,form .clearfix.error .help-block,form .clearfix.error .help-inline{color:#b94a48;}
@@ -235,7 +235,7 @@
 .topbar p{margin:0;line-height:40px;}.topbar p a:hover{background-color:transparent;color:#ffffff;}
 .topbar form{float:left;margin:5px 0 0 0;position:relative;filter:alpha(opacity=100);-khtml-opacity:1;-moz-opacity:1;opacity:1;}
 .topbar form.pull-right{float:right;}
-.topbar input{background-color:#444;background-color:rgba(255, 255, 255, 0.3);font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:normal;font-weight:13px;line-height:1;padding:4px 9px;color:#ffffff;color:rgba(255, 255, 255, 0.75);border:1px solid #111;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);-webkit-transition:none;-moz-transition:none;-ms-transition:none;-o-transition:none;transition:none;}.topbar input:-moz-placeholder{color:#e6e6e6;}
+.topbar input{background-color:#444;background-color:rgba(255, 255, 255, 0.3);font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:normal;font-weight:13px;line-height:1;padding:4px 9px;color:#ffffff;color:rgba(255, 255, 255, 0.75);border:1px solid #111;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.25);-webkit-transform-style:preserve-3d;-webkit-transition:none;-moz-transition:none;-ms-transition:none;-o-transition:none;transition:none;}.topbar input:-moz-placeholder{color:#e6e6e6;}
 .topbar input::-webkit-input-placeholder{color:#e6e6e6;}
 .topbar input:hover{background-color:#bfbfbf;background-color:rgba(255, 255, 255, 0.5);color:#ffffff;}
 .topbar input:focus,.topbar input.focused{outline:0;background-color:#ffffff;color:#404040;text-shadow:0 1px 0 #ffffff;border:0;padding:5px 10px;-webkit-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);-moz-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);box-shadow:0 0 3px rgba(0, 0, 0, 0.15);}
@@ -269,7 +269,7 @@
 .pills a{margin:5px 3px 5px 0;padding:0 15px;line-height:30px;text-shadow:0 1px 1px #ffffff;-webkit-border-radius:15px;-moz-border-radius:15px;border-radius:15px;}.pills a:hover{color:#ffffff;text-decoration:none;text-shadow:0 1px 1px rgba(0, 0, 0, 0.25);background-color:#00438a;}
 .pills .active a{color:#ffffff;text-shadow:0 1px 1px rgba(0, 0, 0, 0.25);background-color:#0069d6;}
 .pills-vertical>li{float:none;}
-.tab-content>.tab-pane,.pill-content>.pill-pane,.tab-content>div,.pill-content>div{display:none;}
+.tab-content>.tab-pane,.pill-content>.pill-pane{display:none;}
 .tab-content>.active,.pill-content>.active{display:block;}
 .breadcrumb{padding:7px 14px;margin:0 0 18px;background-color:#f5f5f5;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#ffffff), to(#f5f5f5));background-image:-moz-linear-gradient(top, #ffffff, #f5f5f5);background-image:-ms-linear-gradient(top, #ffffff, #f5f5f5);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #ffffff), color-stop(100%, #f5f5f5));background-image:-webkit-linear-gradient(top, #ffffff, #f5f5f5);background-image:-o-linear-gradient(top, #ffffff, #f5f5f5);background-image:linear-gradient(top, #ffffff, #f5f5f5);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#f5f5f5', GradientType=0);border:1px solid #ddd;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;}.breadcrumb li{display:inline;text-shadow:0 1px 0 #ffffff;}
 .breadcrumb .divider{padding:0 5px;color:#bfbfbf;}
@@ -283,10 +283,10 @@
 .btn.danger,.alert-message.danger,.btn.error,.alert-message.error{background-color:#c43c35;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#ee5f5b), to(#c43c35));background-image:-moz-linear-gradient(top, #ee5f5b, #c43c35);background-image:-ms-linear-gradient(top, #ee5f5b, #c43c35);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #ee5f5b), color-stop(100%, #c43c35));background-image:-webkit-linear-gradient(top, #ee5f5b, #c43c35);background-image:-o-linear-gradient(top, #ee5f5b, #c43c35);background-image:linear-gradient(top, #ee5f5b, #c43c35);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#c43c35', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#c43c35 #c43c35 #882a25;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);}
 .btn.success,.alert-message.success{background-color:#57a957;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#62c462), to(#57a957));background-image:-moz-linear-gradient(top, #62c462, #57a957);background-image:-ms-linear-gradient(top, #62c462, #57a957);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #62c462), color-stop(100%, #57a957));background-image:-webkit-linear-gradient(top, #62c462, #57a957);background-image:-o-linear-gradient(top, #62c462, #57a957);background-image:linear-gradient(top, #62c462, #57a957);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#57a957', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#57a957 #57a957 #3d773d;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);}
 .btn.info,.alert-message.info{background-color:#339bb9;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#5bc0de), to(#339bb9));background-image:-moz-linear-gradient(top, #5bc0de, #339bb9);background-image:-ms-linear-gradient(top, #5bc0de, #339bb9);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #5bc0de), color-stop(100%, #339bb9));background-image:-webkit-linear-gradient(top, #5bc0de, #339bb9);background-image:-o-linear-gradient(top, #5bc0de, #339bb9);background-image:linear-gradient(top, #5bc0de, #339bb9);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#339bb9', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#339bb9 #339bb9 #22697d;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);}
-.btn{cursor:pointer;display:inline-block;background-color:#e6e6e6;background-repeat:no-repeat;background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);background-image:-ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);padding:5px 14px 6px;text-shadow:0 1px 1px rgba(255, 255, 255, 0.75);color:#333;font-size:13px;line-height:normal;border:1px solid #ccc;border-bottom-color:#bbb;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-webkit-transition:0.1s linear all;-moz-transition:0.1s linear all;-ms-transition:0.1s linear all;-o-transition:0.1s linear all;transition:0.1s linear all;}.btn:hover{background-position:0 -15px;color:#333;text-decoration:none;}
+.btn{cursor:pointer;display:inline-block;background-color:#e6e6e6;background-repeat:no-repeat;background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);background-image:-ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);padding:5px 14px 6px;text-shadow:0 1px 1px rgba(255, 255, 255, 0.75);color:#333;font-size:13px;line-height:normal;border:1px solid #ccc;border-bottom-color:#bbb;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-webkit-transform-style:preserve-3d;-webkit-transition:0.1s linear all;-moz-transition:0.1s linear all;-ms-transition:0.1s linear all;-o-transition:0.1s linear all;transition:0.1s linear all;}.btn:hover{background-position:0 -15px;color:#333;text-decoration:none;}
 .btn:focus{outline:1px dotted #666;}
 .btn.primary{color:#ffffff;background-color:#0064cd;background-repeat:repeat-x;background-image:-khtml-gradient(linear, left top, left bottom, from(#049cdb), to(#0064cd));background-image:-moz-linear-gradient(top, #049cdb, #0064cd);background-image:-ms-linear-gradient(top, #049cdb, #0064cd);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #049cdb), color-stop(100%, #0064cd));background-image:-webkit-linear-gradient(top, #049cdb, #0064cd);background-image:-o-linear-gradient(top, #049cdb, #0064cd);background-image:linear-gradient(top, #049cdb, #0064cd);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#049cdb', endColorstr='#0064cd', GradientType=0);text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);border-color:#0064cd #0064cd #003f81;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);}
-.btn.active,.btn:active{-webkit-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);}
+.btn.active,.btn :active{-webkit-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.25),0 1px 2px rgba(0, 0, 0, 0.05);}
 .btn.disabled{cursor:default;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=65);-khtml-opacity:0.65;-moz-opacity:0.65;opacity:0.65;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
 .btn[disabled]{cursor:default;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=65);-khtml-opacity:0.65;-moz-opacity:0.65;opacity:0.65;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
 .btn.large{font-size:15px;line-height:normal;padding:9px 14px 9px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;}
@@ -320,7 +320,7 @@
 .modal-backdrop{background-color:#000000;position:fixed;top:0;left:0;right:0;bottom:0;z-index:10000;}.modal-backdrop.fade{opacity:0;}
 .modal-backdrop,.modal-backdrop.fade.in{filter:alpha(opacity=80);-khtml-opacity:0.8;-moz-opacity:0.8;opacity:0.8;}
 .modal{position:fixed;top:50%;left:50%;z-index:11000;width:560px;margin:-250px 0 0 -280px;background-color:#ffffff;border:1px solid #999;border:1px solid rgba(0, 0, 0, 0.3);*border:1px solid #999;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.modal .close{margin-top:7px;}
-.modal.fade{-webkit-transition:opacity .3s linear, top .3s ease-out;-moz-transition:opacity .3s linear, top .3s ease-out;-ms-transition:opacity .3s linear, top .3s ease-out;-o-transition:opacity .3s linear, top .3s ease-out;transition:opacity .3s linear, top .3s ease-out;top:-25%;}
+.modal.fade{-webkit-transform-style:preserve-3d;-webkit-transition:opacity .3s linear, top .3s ease-out;-moz-transition:opacity .3s linear, top .3s ease-out;-ms-transition:opacity .3s linear, top .3s ease-out;-o-transition:opacity .3s linear, top .3s ease-out;transition:opacity .3s linear, top .3s ease-out;top:-25%;}
 .modal.fade.in{top:50%;}
 .modal-header{border-bottom:1px solid #eee;padding:5px 15px;}
 .modal-body{padding:15px;}
@@ -344,7 +344,7 @@
 .popover .inner{background:#000000;background:rgba(0, 0, 0, 0.8);padding:3px;overflow:hidden;width:280px;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);}
 .popover .title{background-color:#f5f5f5;padding:9px 15px;line-height:1;-webkit-border-radius:3px 3px 0 0;-moz-border-radius:3px 3px 0 0;border-radius:3px 3px 0 0;border-bottom:1px solid #eee;}
 .popover .content{background-color:#ffffff;padding:14px;-webkit-border-radius:0 0 3px 3px;-moz-border-radius:0 0 3px 3px;border-radius:0 0 3px 3px;-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.popover .content p,.popover .content ul,.popover .content ol{margin-bottom:0;}
-.fade{-webkit-transition:opacity 0.15s linear;-moz-transition:opacity 0.15s linear;-ms-transition:opacity 0.15s linear;-o-transition:opacity 0.15s linear;transition:opacity 0.15s linear;opacity:0;}.fade.in{opacity:1;}
+.fade{-webkit-transform-style:preserve-3d;-webkit-transition:opacity 0.15s linear;-moz-transition:opacity 0.15s linear;-ms-transition:opacity 0.15s linear;-o-transition:opacity 0.15s linear;transition:opacity 0.15s linear;opacity:0;}.fade.in{opacity:1;}
 .label{padding:1px 3px 2px;font-size:9.75px;font-weight:bold;color:#ffffff;text-transform:uppercase;white-space:nowrap;background-color:#bfbfbf;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}.label.important{background-color:#c43c35;}
 .label.warning{background-color:#f89406;}
 .label.success{background-color:#46a546;}
diff --git a/assets/index.html b/assets/index.html
--- a/assets/index.html
+++ b/assets/index.html
@@ -95,32 +95,8 @@
             </tbody>
           </table>
 
-          <h3>Counters</h3>
-          <table id="counter-table" class="condensed-table">
-            <thead>
-              <tr>
-                <th class="span4">Name</th>
-                <th class="span1">Value</th>
-              </tr>
-            </thead>
-            <tbody>
-            </tbody>
-          </table>
-
-          <h3>Gauges</h3>
-          <table id="gauge-table" class="condensed-table">
-            <thead>
-              <tr>
-                <th class="span4">Name</th>
-                <th class="span1">Value</th>
-              </tr>
-            </thead>
-            <tbody>
-            </tbody>
-          </table>
-
-          <h3>Labels</h3>
-          <table id="label-table" class="condensed-table">
+          <h3>Metrics</h3>
+          <table id="metric-table" class="condensed-table">
             <thead>
               <tr>
                 <th class="span4">Name</th>
diff --git a/assets/monitor.js b/assets/monitor.js
--- a/assets/monitor.js
+++ b/assets/monitor.js
@@ -115,7 +115,7 @@
             }
             alertVisible = false;
             for (var i = 0; i < listeners.length; i++) {
-                listeners[i](stats, stats.server_timestamp_millis);
+                listeners[i](stats, stats.ekg.server_timestamp_ms.val);
             }
         }
 
@@ -160,7 +160,7 @@
                                                  prev_stats, prev_time)]);
             }
 
-            // zip lengends with data
+            // zip legends with data
             var res = [];
             for(var i = 0; i < series.length; i++)
                 res.push({ label: series[i].label, data: data[i] });
@@ -195,8 +195,10 @@
             return graph_fn(key, stats, time, prev_stats, prev_time);
         }
 
+        // jQuery has problem with IDs containing dots.
+        var plotId = key.replace(/\./g, "-") + "-plot";
         $("#plots:last").append(
-            '<div id="' + key + '-plot" class="plot-container">' +
+            '<div id="' + plotId + '" class="plot-container">' +
                 '<img src="cross.png" class="close-button"><h3>' + key +
                 '</h3><div class="plot"></div></div>');
         var plot = $("#plots > .plot-container:last > div");
@@ -204,7 +206,7 @@
                 [{ label: label_fn(key), fn: getStats }],
                 { yaxis: { tickFormatter: suffixFormatterGeneric } });
 
-        var plotContainer = $("#" + key + "-plot");
+        var plotContainer = $("#" + plotId);
         var closeButton = plotContainer.find("img");
         closeButton.hide();
         closeButton.click(function () {
@@ -223,101 +225,146 @@
         );
     }
 
-    function addDynamicCounters(table, group_fn, graph_fn, label_fn) {
-        var counters = {};
-        function onDataReceived(stats, time) {
-            $.each(group_fn(stats), function (key, value) {
-                var elem;
-                if (key in counters) {
-                    elem = counters[key];
-                } else {
-                    // Add UI element
-                    table.find("tbody:last").append(
-                        '<tr><td>' + key +
-                            ' <img src="chart_line_add.png" class="graph-button"' +
-                            ' width="16" height="16"' +
-                            ' alt="Add graph" title="Add graph"></td>' +
-                            '<td class="value">N/A</td></tr>');
-                    elem = table.find("tbody > tr > td:last");
-                    counters[key] = elem;
+    function addMetrics(table) {
+        var COUNTER = "c";
+        var GAUGE = "g";
+        var DISTRIBUTION = "d";
+        var metrics = {};
 
-                    var button = table.find("tbody > tr:last > td:first > img");
-                    button.click(function () {
-                        addDynamicPlot(key, button, graph_fn, label_fn);
-                        $(this).hide();
+        function makeDataGetter(key) {
+            var pieces = key.split(".");
+            function get(key, stats, time, prev_stats, prev_time) {
+                var value = stats;
+                $.each(pieces, function(unused_index, piece) {
+                    value = value[piece];
+                });
+                if (value.type === COUNTER) {
+                    if (prev_stats == undefined)
+                        return null;
+                    var prev_value = prev_stats;
+                    $.each(pieces, function(unused_index, piece) {
+                        prev_value = prev_value[piece];
                     });
+                    return 1000 * (value.val - prev_value.val) /
+                        (time - prev_time);
+                } else if (value.type === DISTRIBUTION) {
+                    return value.mean;
+                } else {  // value.type === GAUGE || value.type === LABEL
+                    return value.val;
                 }
-                if (!paused)
-                    elem.text(commaify(value));
-            });
+            }
+            return get;
         }
 
-        subscribe(onDataReceived);
-    }
+        function counterLabel(label) {
+            return label + "/s";
+        }
 
-    function addDynamicLabels(table, group_fn) {
-        var labels = {};
-        function onDataReceived(stats, time) {
-            $.each(group_fn(stats), function (key, value) {
-                var elem;
-                if (key in labels) {
-                    elem = labels[key];
-                } else {
-                    // Add UI element
-                    table.find("tbody:last").append(
-                        '<tr><td>' + key + '</td>' +
-                            '<td class="string">N/A</td></tr>');
-                    elem = table.find("tbody > tr > td:last");
-                    labels[key] = elem;
+        function gaugeLabel(label) {
+            return label;
+        }
+
+        /** Adds the table row. */
+        function addElem(key, value) {
+            var elem;
+            if (key in metrics) {
+                elem = metrics[key];
+            } else {
+                // Add UI element
+                table.find("tbody:last").append(
+                    '<tr><td>' + key +
+                        ' <img src="chart_line_add.png" class="graph-button"' +
+                        ' width="16" height="16"' +
+                        ' alt="Add graph" title="Add graph"></td>' +
+                        '<td class="value">N/A</td></tr>');
+                elem = table.find("tbody > tr > td:last");
+                metrics[key] = elem;
+
+                var button = table.find("tbody > tr:last > td:first > img");
+                var graph_fn = makeDataGetter(key);
+                var label_fn = gaugeLabel;
+                if (value.type === COUNTER) {
+                    label_fn = counterLabel;
                 }
-                if (!paused)
-                    elem.text(value);
-            });
+                button.click(function () {
+                    addDynamicPlot(key, button, graph_fn, label_fn);
+                    $(this).hide();
+                });
+            }
+            if (!paused) {
+                if (value.type === DISTRIBUTION) {
+                    var val = value.mean.toPrecision(8) + '\n+/-' +
+                        Math.sqrt(value.variance).toPrecision(8) + ' sd';
+                } else {  // COUNTER, GAUGE, LABEL
+                    var val = value.val;
+                }
+                if ($.inArray(value.type, [COUNTER, GAUGE]) !== -1) {
+                    val = commaify(val);
+                }
+                elem.text(val);
+            }
         }
 
+        /** Updates UI for all metrics. */
+        function onDataReceived(stats, time) {
+            function build(prefix, obj) {
+                $.each(obj, function (suffix, value) {
+                    if (value.hasOwnProperty("type")) {
+                        var key = prefix + suffix;
+                        addElem(key, value);
+                    } else {
+                        build(prefix + suffix + '.', value);
+                    }
+                });
+            }
+            build('', stats);
+        }
+
         subscribe(onDataReceived);
     }
 
     function initAll() {
         // Metrics
         var current_bytes_used = function (stats) {
-            return stats.gauges.current_bytes_used;
+            return stats.rts.gc.current_bytes_used.val;
         };
         var max_bytes_used = function (stats) {
-            return stats.gauges.max_bytes_used;
+            return stats.rts.gc.max_bytes_used.val;
         };
         var max_bytes_slop = function (stats) {
-            return stats.gauges.max_bytes_slop;
+            return stats.rts.gc.max_bytes_slop.val;
         };
         var current_bytes_slop = function (stats) {
-            return stats.gauges.current_bytes_slop;
+            return stats.rts.gc.current_bytes_slop.val;
         };
         var productivity_wall_percent = function (stats, time, prev_stats, prev_time) {
             if (prev_stats == undefined)
                 return null;
-            var mutator_seconds = stats.counters.mutator_wall_seconds -
-                prev_stats.counters.mutator_wall_seconds;
-            var gc_seconds = stats.counters.gc_wall_seconds -
-                prev_stats.counters.gc_wall_seconds;
-            return 100 * mutator_seconds / (mutator_seconds + gc_seconds);
+            var mutator_ms = stats.rts.gc.mutator_wall_ms.val -
+                prev_stats.rts.gc.mutator_wall_ms.val;
+            var gc_ms = stats.rts.gc.gc_wall_ms.val -
+                prev_stats.rts.gc.gc_wall_ms.val;
+            return 100 * mutator_ms / (mutator_ms + gc_ms);
         };
         var productivity_cpu_percent = function (stats, time, prev_stats, prev_time) {
             if (prev_stats == undefined)
                 return null;
-            var mutator_seconds = stats.counters.mutator_cpu_seconds -
-                prev_stats.counters.mutator_cpu_seconds;
-            var gc_seconds = stats.counters.gc_cpu_seconds -
-                prev_stats.counters.gc_cpu_seconds;
-            return 100 * mutator_seconds / (mutator_seconds + gc_seconds);
+            var mutator_ms = stats.rts.gc.mutator_cpu_ms.val -
+                prev_stats.rts.gc.mutator_cpu_ms.val;
+            var gc_ms = stats.rts.gc.gc_cpu_ms.val -
+                prev_stats.rts.gc.gc_cpu_ms.val;
+            return 100 * mutator_ms / (mutator_ms + gc_ms);
         };
         var allocation_rate = function (stats, time, prev_stats, prev_time) {
             if (prev_stats == undefined)
                 return null;
-            return 1000 * (stats.counters.bytes_allocated -
-                           prev_stats.counters.bytes_allocated) /
+            return 1000 * (stats.rts.gc.bytes_allocated.val -
+                           prev_stats.rts.gc.bytes_allocated.val) /
                 (time - prev_time);
         };
 
+        addMetrics($("#metric-table"));
+
         // Plots
         addPlot($("#current-bytes-used-plot > div"),
                 [{ label: "residency", fn: current_bytes_used }],
@@ -330,7 +377,7 @@
                  { label: "cpu time", fn: productivity_cpu_percent }],
                 { yaxis: { tickDecimals: 1, tickFormatter: percentFormatter } });
 
-        // Counters
+        // GC and memory statistics
         addCounter($("#max-bytes-used"), max_bytes_used, formatSuffix);
         addCounter($("#current-bytes-used"), current_bytes_used, formatSuffix);
         addCounter($("#max-bytes-slop"), max_bytes_slop, formatSuffix);
@@ -338,29 +385,6 @@
         addCounter($("#productivity-wall"), productivity_wall_percent, formatPercent);
         addCounter($("#productivity-cpu"), productivity_cpu_percent, formatPercent);
         addCounter($("#allocation-rate"), allocation_rate, formatRate);
-
-        addDynamicCounters($("#counter-table"), function (stats) {
-            return stats.counters;
-        }, function (key, stats, time, prev_stats, prev_time) {
-            if (prev_stats == undefined)
-                return null;
-            return 1000 * (stats.counters[key] - prev_stats.counters[key]) /
-                (time - prev_time);
-        }, function (label) {
-            return label + "/s";
-        });
-
-        addDynamicCounters($("#gauge-table"), function (stats) {
-            return stats.gauges;
-        }, function (key, stats, time) {
-            return stats.gauges[key];
-        }, function (label) {
-            return label;
-        });
-
-        addDynamicLabels($("#label-table"), function (stats) {
-            return stats.labels;
-        });
     }
 
     initAll();
diff --git a/ekg.cabal b/ekg.cabal
--- a/ekg.cabal
+++ b/ekg.cabal
@@ -1,11 +1,12 @@
 name:                ekg
-version:             0.3.1.4
+version:             0.4.0.0
 synopsis:            Remote monitoring of processes
 description:
   This library lets you remotely monitor a running process over HTTP.
   It provides a simple way to integrate a monitoring server into any
   application.
 homepage:            https://github.com/tibbe/ekg
+bug-reports:         https://github.com/tibbe/ekg/issues
 license:             BSD3
 license-file:        LICENSE
 author:              Johan Tibell
@@ -19,28 +20,28 @@
                      assets/chart_line_add.png assets/cross.png
 extra-source-files:  LICENSE.icons LICENSE.javascript README.md
                      assets/jquery-1.6.4.js assets/jquery.flot.js
-                     examples/Basic.hs
+                     examples/Basic.hs CHANGES.md
+cabal-version:       >= 1.8
 
 library
-  exposed-modules:     System.Remote.Counter
-                       System.Remote.Gauge
-                       System.Remote.Label
-                       System.Remote.Monitoring
+  exposed-modules:
+    System.Remote.Counter
+    System.Remote.Gauge
+    System.Remote.Label
+    System.Remote.Monitoring
 
-  other-modules:       Paths_ekg
-                       System.Remote.Common
-                       System.Remote.Counter.Internal
-                       System.Remote.Gauge.Internal
-                       System.Remote.Label.Internal
-                       System.Remote.Snap
+  other-modules:
+    Paths_ekg
+    System.Remote.Json
+    System.Remote.Snap
 
   build-depends:
     aeson < 0.8,
     base >= 4.5 && < 5,
     bytestring < 1.0,
-    containers < 0.6,
+    ekg-core >= 0.1 && < 0.2,
     filepath < 1.4,
-    network < 2.5,
+    network < 2.6,
     snap-core < 0.10,
     snap-server < 0.10,
     text < 1.2,
diff --git a/examples/Basic.hs b/examples/Basic.hs
--- a/examples/Basic.hs
+++ b/examples/Basic.hs
@@ -7,6 +7,8 @@
 import Control.Concurrent
 import Control.Exception
 import Data.List
+import Data.Time.Clock.POSIX (getPOSIXTime)
+import qualified System.Remote.Event as Event
 import qualified System.Remote.Counter as Counter
 import qualified System.Remote.Label as Label
 import System.Remote.Monitoring
@@ -23,10 +25,22 @@
     handle <- forkServer "localhost" 8000
     counter <- getCounter "iterations" handle
     label <- getLabel "args" handle
+    event <- getEvent "runtime" handle
     Label.set label "some text string"
     let loop n = do
-            evaluate $ mean [1..n]
+            t <- timed $ evaluate $ mean [1..n]
+            Event.add event t
             threadDelay 2000
             Counter.inc counter
             loop n
     loop 1000000
+
+timed :: IO a -> IO Double
+timed m = do
+    start <- getTime
+    m
+    end <- getTime
+    return $! end - start
+
+getTime :: IO Double
+getTime = realToFrac `fmap` getPOSIXTime
