changeset 3174:8ea7c4546c3a

primitives to collect metrics in Orthanc
author Sebastien Jodogne <s.jodogne@gmail.com>
date Tue, 29 Jan 2019 15:15:48 +0100
parents 096f4a29f223
children 574890d14c92
files Core/Enumerations.cpp Core/Enumerations.h Core/MetricsRegistry.cpp Core/MetricsRegistry.h NEWS OrthancServer/OrthancFindRequestHandler.cpp OrthancServer/OrthancMoveRequestHandler.cpp OrthancServer/OrthancRestApi/OrthancRestResources.cpp OrthancServer/OrthancRestApi/OrthancRestSystem.cpp OrthancServer/ServerContext.cpp OrthancServer/ServerContext.h OrthancServer/ServerIndex.cpp OrthancServer/ServerIndex.h Resources/CMake/OrthancFrameworkConfiguration.cmake Resources/CMake/OrthancFrameworkParameters.cmake Resources/Configuration.json UnitTestsSources/ServerIndexTests.cpp UnitTestsSources/UnitTestsMain.cpp
diffstat 18 files changed, 816 insertions(+), 153 deletions(-) [+]
line wrap: on
line diff
--- a/Core/Enumerations.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/Core/Enumerations.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -1103,6 +1103,10 @@
                 
       case MimeType_Woff:
         return MIME_WOFF;
+
+      case MimeType_PrometheusText:
+        // https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format
+        return "text/plain; version=0.0.4";
                 
       default:
         throw OrthancException(ErrorCode_ParameterOutOfRange);
--- a/Core/Enumerations.h	Tue Jan 29 10:34:00 2019 +0100
+++ b/Core/Enumerations.h	Tue Jan 29 15:15:48 2019 +0100
@@ -104,8 +104,9 @@
     MimeType_Svg,
     MimeType_WebAssembly,
     MimeType_Xml,
-    MimeType_Woff,  // Web Open Font Format
-    MimeType_Zip
+    MimeType_Woff,           // Web Open Font Format
+    MimeType_Zip,
+    MimeType_PrometheusText  // Prometheus text-based exposition format (for metrics)
   };
 
   
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Core/MetricsRegistry.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -0,0 +1,321 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2019 Osimis S.A., Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * In addition, as a special exception, the copyright holders of this
+ * program give permission to link the code of its release with the
+ * OpenSSL project's "OpenSSL" library (or with modified versions of it
+ * that use the same license as the "OpenSSL" library), and distribute
+ * the linked executables. You must obey the GNU General Public License
+ * in all respects for all of the code used other than "OpenSSL". If you
+ * modify file(s) with this exception, you may extend this exception to
+ * your version of the file(s), but you are not obligated to do so. If
+ * you do not wish to do so, delete this exception statement from your
+ * version. If you delete this exception statement from all source files
+ * in the program, then also delete it here.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "PrecompiledHeaders.h"
+#include "MetricsRegistry.h"
+
+#include "OrthancException.h"
+#include "ChunkedBuffer.h"
+
+namespace Orthanc
+{
+  static const boost::posix_time::ptime GetNow()
+  {
+    return boost::posix_time::second_clock::universal_time();
+  }
+
+
+  class MetricsRegistry::Item
+  {
+  private:
+    MetricsType               type_;
+    boost::posix_time::ptime  time_;
+    bool                      hasValue_;
+    float                     value_;
+    
+    void Touch(float value,
+               const boost::posix_time::ptime& now)
+    {
+      hasValue_ = true;
+      value_ = value;
+      time_ = now;
+    }
+
+    void Touch(float value)
+    {
+      Touch(value, GetNow());
+    }
+
+    void UpdateMax(float value,
+                   int duration)
+    {
+      if (hasValue_)
+      {
+        const boost::posix_time::ptime now = GetNow();
+
+        if (value > value_ ||
+            (now - time_).total_seconds() > duration)
+        {
+          Touch(value, now);
+        }
+      }
+      else
+      {
+        Touch(value);
+      }
+    }
+    
+    void UpdateMin(float value,
+                   int duration)
+    {
+      if (hasValue_)
+      {
+        const boost::posix_time::ptime now = GetNow();
+        
+        if (value < value_ ||
+            (now - time_).total_seconds() > duration)
+        {
+          Touch(value, now);
+        }
+      }
+      else
+      {
+        Touch(value);
+      }
+    }
+
+  public:
+    Item(MetricsType type) :
+    type_(type),
+    hasValue_(false)
+    {
+    }
+
+    MetricsType GetType() const
+    {
+      return type_;
+    }
+
+    void Update(float value)
+    {
+      switch (type_)
+      {
+        case MetricsType_Default:
+          Touch(value);
+          break;
+          
+        case MetricsType_MaxOver10Seconds:
+          UpdateMax(value, 10);
+          break;
+
+        case MetricsType_MaxOver1Minute:
+          UpdateMax(value, 60);
+          break;
+
+        case MetricsType_MinOver10Seconds:
+          UpdateMin(value, 10);
+          break;
+
+        case MetricsType_MinOver1Minute:
+          UpdateMin(value, 60);
+          break;
+
+        default:
+          throw OrthancException(ErrorCode_NotImplemented);
+      }
+    }
+
+    bool HasValue() const
+    {
+      return hasValue_;
+    }
+
+    const boost::posix_time::ptime& GetTime() const
+    {
+      if (hasValue_)
+      {
+        return time_;
+      }
+      else
+      {
+        throw OrthancException(ErrorCode_BadSequenceOfCalls);
+      }
+    }
+
+    float GetValue() const
+    {
+      if (hasValue_)
+      {
+        return value_;
+      }
+      else
+      {
+        throw OrthancException(ErrorCode_BadSequenceOfCalls);
+      }
+    }
+  };
+
+
+  MetricsRegistry::~MetricsRegistry()
+  {
+    for (Content::iterator it = content_.begin(); it != content_.end(); ++it)
+    {
+      assert(it->second != NULL);
+      delete it->second;
+    }
+  }
+
+
+  void MetricsRegistry::SetEnabled(bool enabled)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+    enabled_ = enabled;
+  }
+
+
+  void MetricsRegistry::Register(const std::string& name,
+                                 MetricsType type)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    Content::iterator found = content_.find(name);
+
+    if (found == content_.end())
+    {
+      content_[name] = new Item(type);
+    }
+    else
+    {
+      assert(found->second != NULL);
+
+      // This metrics already exists: Only recreate it if there is a
+      // mismatch in the type of metrics
+      if (found->second->GetType() != type)
+      {
+        delete found->second;
+        found->second = new Item(type);
+      }
+    }    
+  }
+
+
+  void MetricsRegistry::SetValueInternal(const std::string& name,
+                                         float value,
+                                         MetricsType type)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    Content::iterator found = content_.find(name);
+
+    if (found == content_.end())
+    {
+      std::auto_ptr<Item> item(new Item(type));
+      item->Update(value);
+      content_[name] = item.release();
+    }
+    else
+    {
+      assert(found->second != NULL);
+      found->second->Update(value);
+    }
+  }
+
+
+  MetricsType MetricsRegistry::GetMetricsType(const std::string& name)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    Content::const_iterator found = content_.find(name);
+
+    if (found == content_.end())
+    {
+      throw OrthancException(ErrorCode_InexistentItem);
+    }
+    else
+    {
+      assert(found->second != NULL);
+      return found->second->GetType();
+    }
+  }
+
+
+  void MetricsRegistry::ExportPrometheusText(std::string& s)
+  {
+    // https://www.boost.org/doc/libs/1_69_0/doc/html/date_time/examples.html#date_time.examples.seconds_since_epoch
+    static const boost::posix_time::ptime EPOCH(boost::gregorian::date(1970, 1, 1));
+
+    boost::mutex::scoped_lock lock(mutex_);
+
+    s.clear();
+
+    if (!enabled_)
+    {
+      return;
+    }
+
+    ChunkedBuffer buffer;
+
+    for (Content::const_iterator it = content_.begin();
+         it != content_.end(); ++it)
+    {
+      assert(it->second != NULL);
+
+      if (it->second->HasValue())
+      {
+        boost::posix_time::time_duration diff = it->second->GetTime() - EPOCH;
+
+        std::string line = (it->first + " " +
+                            boost::lexical_cast<std::string>(it->second->GetValue()) + " " + 
+                            boost::lexical_cast<std::string>(diff.total_milliseconds()) + "\n");
+
+        buffer.AddChunk(line);
+      }
+    }
+
+    buffer.Flatten(s);
+  }
+
+
+  void  MetricsRegistry::Timer::Start()
+  {
+    if (registry_.IsEnabled())
+    {
+      active_ = true;
+      start_ = GetNow();
+    }
+    else
+    {
+      active_ = false;
+    }
+  }
+
+
+  MetricsRegistry::Timer::~Timer()
+  {
+    if (active_)
+    {   
+      boost::posix_time::time_duration diff = GetNow() - start_;
+      registry_.SetValue(name_, diff.total_milliseconds(), type_);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Core/MetricsRegistry.h	Tue Jan 29 15:15:48 2019 +0100
@@ -0,0 +1,148 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2019 Osimis S.A., Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * In addition, as a special exception, the copyright holders of this
+ * program give permission to link the code of its release with the
+ * OpenSSL project's "OpenSSL" library (or with modified versions of it
+ * that use the same license as the "OpenSSL" library), and distribute
+ * the linked executables. You must obey the GNU General Public License
+ * in all respects for all of the code used other than "OpenSSL". If you
+ * modify file(s) with this exception, you may extend this exception to
+ * your version of the file(s), but you are not obligated to do so. If
+ * you do not wish to do so, delete this exception statement from your
+ * version. If you delete this exception statement from all source files
+ * in the program, then also delete it here.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#if !defined(ORTHANC_SANDBOXED)
+#  error The macro ORTHANC_SANDBOXED must be defined
+#endif
+
+#if ORTHANC_SANDBOXED == 1
+#  error The class MetricsRegistry cannot be used in sandboxed environments
+#endif
+
+#include <boost/thread/mutex.hpp>
+#include <boost/date_time/posix_time/posix_time.hpp>
+
+namespace Orthanc
+{
+  enum MetricsType
+  {
+    MetricsType_Default,
+    MetricsType_MaxOver10Seconds,
+    MetricsType_MaxOver1Minute,
+    MetricsType_MinOver10Seconds,
+    MetricsType_MinOver1Minute
+  };
+  
+  class MetricsRegistry : public boost::noncopyable
+  {
+  private:
+    class Item;
+
+    typedef std::map<std::string, Item*>   Content;
+
+    bool          enabled_;
+    boost::mutex  mutex_;
+    Content       content_;
+
+    void SetValueInternal(const std::string& name,
+                          float value,
+                          MetricsType type);
+
+  public:
+    MetricsRegistry() :
+      enabled_(true)
+    {
+    }
+
+    ~MetricsRegistry();
+
+    bool IsEnabled() const
+    {
+      return enabled_;
+    }
+
+    void SetEnabled(bool enabled);
+
+    void Register(const std::string& name,
+                  MetricsType type);
+
+    void SetValue(const std::string& name,
+                  float value,
+                  MetricsType type)
+    {
+      // Inlining to avoid loosing time if metrics are disabled
+      if (enabled_)
+      {
+        SetValueInternal(name, value, type);
+      }
+    }
+    
+    void SetValue(const std::string& name,
+                  float value)
+    {
+      SetValue(name, value, MetricsType_Default);
+    }
+
+    MetricsType GetMetricsType(const std::string& name);
+
+    // https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format
+    void ExportPrometheusText(std::string& s);
+
+
+    class Timer : public boost::noncopyable
+    {
+    private:
+      MetricsRegistry&          registry_;
+      std::string               name_;
+      MetricsType               type_;
+      bool                      active_;
+      boost::posix_time::ptime  start_;
+
+      void Start();
+
+    public:
+      Timer(MetricsRegistry& registry,
+            const std::string& name) :
+        registry_(registry),
+        name_(name),
+        type_(MetricsType_MaxOver10Seconds)
+      {
+        Start();
+      }
+
+      Timer(MetricsRegistry& registry,
+            const std::string& name,
+            MetricsType type) :
+        registry_(registry),
+        name_(name),
+        type_(type)
+      {
+        Start();
+      }
+
+      ~Timer();
+    };
+  };
+}
--- a/NEWS	Tue Jan 29 10:34:00 2019 +0100
+++ b/NEWS	Tue Jan 29 15:15:48 2019 +0100
@@ -1,6 +1,18 @@
 Pending changes in the mainline
 ===============================
 
+General
+-------
+
+* New configuration option: "MetricsEnabled" to track the metrics of Orthanc
+
+REST API
+--------
+
+* API version has been upgraded to 1.4
+* New URI "/tools/metrics" to dynamically enable/disable the collection of metrics
+* New URI "/tools/metrics-prometheus" to retrieve metrics using Prometheus text format
+
 
 Version 1.5.3 (2019-01-25)
 ==========================
--- a/OrthancServer/OrthancFindRequestHandler.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/OrthancFindRequestHandler.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -38,6 +38,7 @@
 #include "../Core/DicomParsing/FromDcmtkBridge.h"
 #include "../Core/Logging.h"
 #include "../Core/Lua/LuaFunctionCall.h"
+#include "../Core/MetricsRegistry.h"
 #include "OrthancConfiguration.h"
 #include "Search/DatabaseLookup.h"
 #include "ServerContext.h"
@@ -551,6 +552,8 @@
                                          const std::string& calledAet,
                                          ModalityManufacturer manufacturer)
   {
+    MetricsRegistry::Timer timer(context_.GetMetricsRegistry(), "orthanc_find_scp_duration_ms");
+
     /**
      * Possibly apply the user-supplied Lua filter.
      **/
--- a/OrthancServer/OrthancMoveRequestHandler.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/OrthancMoveRequestHandler.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -37,6 +37,7 @@
 #include "../../Core/DicomParsing/FromDcmtkBridge.h"
 #include "../Core/DicomFormat/DicomArray.h"
 #include "../Core/Logging.h"
+#include "../Core/MetricsRegistry.h"
 #include "OrthancConfiguration.h"
 #include "ServerContext.h"
 #include "ServerJobs/DicomModalityStoreJob.h"
@@ -280,6 +281,8 @@
                                                           const std::string& calledAet,
                                                           uint16_t originatorId)
   {
+    MetricsRegistry::Timer timer(context_.GetMetricsRegistry(), "orthanc_move_scp_duration_ms");
+
     LOG(WARNING) << "Move-SCU request received for AET \"" << targetAet << "\"";
 
     {
--- a/OrthancServer/OrthancRestApi/OrthancRestResources.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/OrthancRestApi/OrthancRestResources.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -645,12 +645,47 @@
   }
 
 
-
   static void GetResourceStatistics(RestApiGetCall& call)
   {
+    static const uint64_t MEGA_BYTES = 1024 * 1024;
+
     std::string publicId = call.GetUriComponent("id", "");
-    Json::Value result;
-    OrthancRestApi::GetIndex(call).GetStatistics(result, publicId);
+
+    ResourceType type;
+    uint64_t diskSize, uncompressedSize, dicomDiskSize, dicomUncompressedSize;
+    unsigned int countStudies, countSeries, countInstances;
+    OrthancRestApi::GetIndex(call).GetResourceStatistics(
+      type, diskSize, uncompressedSize, countStudies, countSeries, 
+      countInstances, dicomDiskSize, dicomUncompressedSize, publicId);
+
+    Json::Value result = Json::objectValue;
+    result["DiskSize"] = boost::lexical_cast<std::string>(diskSize);
+    result["DiskSizeMB"] = static_cast<unsigned int>(diskSize / MEGA_BYTES);
+    result["UncompressedSize"] = boost::lexical_cast<std::string>(uncompressedSize);
+    result["UncompressedSizeMB"] = static_cast<unsigned int>(uncompressedSize / MEGA_BYTES);
+
+    result["DicomDiskSize"] = boost::lexical_cast<std::string>(dicomDiskSize);
+    result["DicomDiskSizeMB"] = static_cast<unsigned int>(dicomDiskSize / MEGA_BYTES);
+    result["DicomUncompressedSize"] = boost::lexical_cast<std::string>(dicomUncompressedSize);
+    result["DicomUncompressedSizeMB"] = static_cast<unsigned int>(dicomUncompressedSize / MEGA_BYTES);
+
+    switch (type)
+    {
+      // Do NOT add "break" below this point!
+      case ResourceType_Patient:
+        result["CountStudies"] = countStudies;
+
+      case ResourceType_Study:
+        result["CountSeries"] = countSeries;
+
+      case ResourceType_Series:
+        result["CountInstances"] = countInstances;
+
+      case ResourceType_Instance:
+      default:
+        break;
+    }
+
     call.GetOutput().AnswerJson(result);
   }
 
--- a/OrthancServer/OrthancRestApi/OrthancRestSystem.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/OrthancRestApi/OrthancRestSystem.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -35,6 +35,7 @@
 #include "OrthancRestApi.h"
 
 #include "../../Core/DicomParsing/FromDcmtkBridge.h"
+#include "../../Core/MetricsRegistry.h"
 #include "../../Plugins/Engine/OrthancPlugins.h"
 #include "../../Plugins/Engine/PluginsManager.h"
 #include "../OrthancConfiguration.h"
@@ -93,8 +94,22 @@
 
   static void GetStatistics(RestApiGetCall& call)
   {
+    static const uint64_t MEGA_BYTES = 1024 * 1024;
+
+    uint64_t diskSize, uncompressedSize, countPatients, countStudies, countSeries, countInstances;
+    OrthancRestApi::GetIndex(call).GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                                                       countStudies, countSeries, countInstances);
+    
     Json::Value result = Json::objectValue;
-    OrthancRestApi::GetIndex(call).ComputeStatistics(result);
+    result["TotalDiskSize"] = boost::lexical_cast<std::string>(diskSize);
+    result["TotalUncompressedSize"] = boost::lexical_cast<std::string>(uncompressedSize);
+    result["TotalDiskSizeMB"] = static_cast<unsigned int>(diskSize / MEGA_BYTES);
+    result["TotalUncompressedSizeMB"] = static_cast<unsigned int>(uncompressedSize / MEGA_BYTES);
+    result["CountPatients"] = static_cast<unsigned int>(countPatients);
+    result["CountStudies"] = static_cast<unsigned int>(countStudies);
+    result["CountSeries"] = static_cast<unsigned int>(countSeries);
+    result["CountInstances"] = static_cast<unsigned int>(countInstances);
+
     call.GetOutput().AnswerJson(result);
   }
 
@@ -390,6 +405,65 @@
   }
 
   
+  static void GetMetricsPrometheus(RestApiGetCall& call)
+  {
+    static const uint64_t MEGA_BYTES = 1024 * 1024;
+
+    ServerContext& context = OrthancRestApi::GetContext(call);
+
+    MetricsRegistry& registry = context.GetMetricsRegistry();
+
+    uint64_t diskSize, uncompressedSize, countPatients, countStudies, countSeries, countInstances;
+    context.GetIndex().GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                                           countStudies, countSeries, countInstances);
+
+    registry.SetValue("orthanc_disk_size_mb", static_cast<unsigned int>(diskSize / MEGA_BYTES));
+    registry.SetValue("orthanc_uncompressed_size_mb", static_cast<unsigned int>(diskSize / MEGA_BYTES));
+    registry.SetValue("orthanc_count_patients", static_cast<unsigned int>(countPatients));
+    registry.SetValue("orthanc_count_studies", static_cast<unsigned int>(countStudies));
+    registry.SetValue("orthanc_count_series", static_cast<unsigned int>(countSeries));
+    registry.SetValue("orthanc_count_instances", static_cast<unsigned int>(countInstances));
+    
+    std::string s;
+    registry.ExportPrometheusText(s);
+
+    call.GetOutput().AnswerBuffer(s, MimeType_PrometheusText);
+  }
+
+
+  static void GetMetricsEnabled(RestApiGetCall& call)
+  {
+    bool enabled = OrthancRestApi::GetContext(call).GetMetricsRegistry().IsEnabled();
+    call.GetOutput().AnswerBuffer(enabled ? "1" : "0", MimeType_PlainText);
+  }
+
+
+  static void PutMetricsEnabled(RestApiPutCall& call)
+  {
+    bool enabled;
+
+    std::string body(call.GetBodyData());
+
+    if (body == "1")
+    {
+      enabled = true;
+    }
+    else if (body == "0")
+    {
+      enabled = false;
+    }
+    else
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange,
+                             "The HTTP body must be 0 or 1, but found: " + body);
+    }
+
+    // Success
+    OrthancRestApi::GetContext(call).GetMetricsRegistry().SetEnabled(enabled);
+    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
+  }
+
+
   void OrthancRestApi::RegisterSystem()
   {
     Register("/", ServeRoot);
@@ -402,6 +476,9 @@
     Register("/tools/dicom-conformance", GetDicomConformanceStatement);
     Register("/tools/default-encoding", GetDefaultEncoding);
     Register("/tools/default-encoding", SetDefaultEncoding);
+    Register("/tools/metrics", GetMetricsEnabled);
+    Register("/tools/metrics", PutMetricsEnabled);
+    Register("/tools/metrics-prometheus", GetMetricsPrometheus);
 
     Register("/plugins", ListPlugins);
     Register("/plugins/{id}", GetPlugin);
--- a/OrthancServer/ServerContext.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/ServerContext.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -41,6 +41,7 @@
 #include "../Core/HttpServer/HttpStreamTranscoder.h"
 #include "../Core/JobsEngine/SetOfInstancesJob.h"
 #include "../Core/Logging.h"
+#include "../Core/MetricsRegistry.h"
 #include "../Plugins/Engine/OrthancPlugins.h"
 
 #include "OrthancConfiguration.h"
@@ -237,7 +238,8 @@
 #endif
     done_(false),
     haveJobsChanged_(false),
-    isJobsEngineUnserialized_(false)
+    isJobsEngineUnserialized_(false),
+    metricsRegistry_(new MetricsRegistry)
   {
     {
       OrthancConfiguration::ReaderLock lock;
@@ -249,6 +251,7 @@
       defaultLocalAet_ = lock.GetConfiguration().GetStringParameter("DicomAet", "ORTHANC");
       jobsEngine_.SetWorkersCount(lock.GetConfiguration().GetUnsignedIntegerParameter("ConcurrentJobs", 2));
       saveJobs_ = lock.GetConfiguration().GetBooleanParameter("SaveJobs", true);
+      metricsRegistry_->SetEnabled(lock.GetConfiguration().GetBooleanParameter("MetricsEnabled", true));
     }
 
     jobsEngine_.SetThreadSleep(unitTesting ? 20 : 200);
--- a/OrthancServer/ServerContext.h	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/ServerContext.h	Tue Jan 29 15:15:48 2019 +0100
@@ -46,6 +46,7 @@
   class DicomInstanceToStore;
   class IStorageArea;
   class JobsEngine;
+  class MetricsRegistry;
   class OrthancPlugins;
   class ParsedDicomFile;
   class RestApiOutput;
@@ -218,6 +219,8 @@
     OrthancHttpHandler  httpHandler_;
     bool saveJobs_;
 
+    std::auto_ptr<MetricsRegistry>  metricsRegistry_;
+
   public:
     class DicomCacheLocker : public boost::noncopyable
     {
@@ -394,5 +397,10 @@
     void SignalUpdatedModalities();
 
     void SignalUpdatedPeers();
+
+    MetricsRegistry& GetMetricsRegistry()
+    {
+      return *metricsRegistry_;
+    }
   };
 }
--- a/OrthancServer/ServerIndex.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/ServerIndex.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -972,24 +972,21 @@
   }
 
 
-  void ServerIndex::ComputeStatistics(Json::Value& target)
+  void ServerIndex::GetGlobalStatistics(/* out */ uint64_t& diskSize,
+                                        /* out */ uint64_t& uncompressedSize,
+                                        /* out */ uint64_t& countPatients, 
+                                        /* out */ uint64_t& countStudies, 
+                                        /* out */ uint64_t& countSeries, 
+                                        /* out */ uint64_t& countInstances)
   {
     boost::mutex::scoped_lock lock(mutex_);
-    target = Json::objectValue;
-
-    uint64_t cs = db_.GetTotalCompressedSize();
-    uint64_t us = db_.GetTotalUncompressedSize();
-    target["TotalDiskSize"] = boost::lexical_cast<std::string>(cs);
-    target["TotalUncompressedSize"] = boost::lexical_cast<std::string>(us);
-    target["TotalDiskSizeMB"] = static_cast<unsigned int>(cs / MEGA_BYTES);
-    target["TotalUncompressedSizeMB"] = static_cast<unsigned int>(us / MEGA_BYTES);
-
-    target["CountPatients"] = static_cast<unsigned int>(db_.GetResourceCount(ResourceType_Patient));
-    target["CountStudies"] = static_cast<unsigned int>(db_.GetResourceCount(ResourceType_Study));
-    target["CountSeries"] = static_cast<unsigned int>(db_.GetResourceCount(ResourceType_Series));
-    target["CountInstances"] = static_cast<unsigned int>(db_.GetResourceCount(ResourceType_Instance));
-  }          
-
+    diskSize = db_.GetTotalCompressedSize();
+    uncompressedSize = db_.GetTotalUncompressedSize();
+    countPatients = db_.GetResourceCount(ResourceType_Patient);
+    countStudies = db_.GetResourceCount(ResourceType_Study);
+    countSeries = db_.GetResourceCount(ResourceType_Series);
+    countInstances = db_.GetResourceCount(ResourceType_Instance);
+  }
 
   
   SeriesStatus ServerIndex::GetSeriesStatus(int64_t id,
@@ -1931,18 +1928,26 @@
   }
 
 
-  void ServerIndex::GetStatisticsInternal(/* out */ uint64_t& diskSize, 
+  void ServerIndex::GetResourceStatistics(/* out */ ResourceType& type,
+                                          /* out */ uint64_t& diskSize, 
                                           /* out */ uint64_t& uncompressedSize, 
                                           /* out */ unsigned int& countStudies, 
                                           /* out */ unsigned int& countSeries, 
                                           /* out */ unsigned int& countInstances, 
                                           /* out */ uint64_t& dicomDiskSize, 
                                           /* out */ uint64_t& dicomUncompressedSize, 
-                                          /* in  */ int64_t id,
-                                          /* in  */ ResourceType type)
+                                          const std::string& publicId)
   {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    int64_t top;
+    if (!db_.LookupResource(top, type, publicId))
+    {
+      throw OrthancException(ErrorCode_UnknownResource);
+    }
+
     std::stack<int64_t> toExplore;
-    toExplore.push(id);
+    toExplore.push(top);
 
     countInstances = 0;
     countSeries = 0;
@@ -2023,83 +2028,6 @@
   }
 
 
-
-  void ServerIndex::GetStatistics(Json::Value& target,
-                                  const std::string& publicId)
-  {
-    boost::mutex::scoped_lock lock(mutex_);
-
-    ResourceType type;
-    int64_t top;
-    if (!db_.LookupResource(top, type, publicId))
-    {
-      throw OrthancException(ErrorCode_UnknownResource);
-    }
-
-    uint64_t uncompressedSize;
-    uint64_t diskSize;
-    uint64_t dicomUncompressedSize;
-    uint64_t dicomDiskSize;
-    unsigned int countStudies;
-    unsigned int countSeries;
-    unsigned int countInstances;
-    GetStatisticsInternal(diskSize, uncompressedSize, countStudies, 
-                          countSeries, countInstances, dicomDiskSize, dicomUncompressedSize, top, type);
-
-    target = Json::objectValue;
-    target["DiskSize"] = boost::lexical_cast<std::string>(diskSize);
-    target["DiskSizeMB"] = static_cast<unsigned int>(diskSize / MEGA_BYTES);
-    target["UncompressedSize"] = boost::lexical_cast<std::string>(uncompressedSize);
-    target["UncompressedSizeMB"] = static_cast<unsigned int>(uncompressedSize / MEGA_BYTES);
-
-    target["DicomDiskSize"] = boost::lexical_cast<std::string>(dicomDiskSize);
-    target["DicomDiskSizeMB"] = static_cast<unsigned int>(dicomDiskSize / MEGA_BYTES);
-    target["DicomUncompressedSize"] = boost::lexical_cast<std::string>(dicomUncompressedSize);
-    target["DicomUncompressedSizeMB"] = static_cast<unsigned int>(dicomUncompressedSize / MEGA_BYTES);
-
-    switch (type)
-    {
-      // Do NOT add "break" below this point!
-      case ResourceType_Patient:
-        target["CountStudies"] = countStudies;
-
-      case ResourceType_Study:
-        target["CountSeries"] = countSeries;
-
-      case ResourceType_Series:
-        target["CountInstances"] = countInstances;
-
-      case ResourceType_Instance:
-      default:
-        break;
-    }
-  }
-
-
-  void ServerIndex::GetStatistics(/* out */ uint64_t& diskSize, 
-                                  /* out */ uint64_t& uncompressedSize, 
-                                  /* out */ unsigned int& countStudies, 
-                                  /* out */ unsigned int& countSeries, 
-                                  /* out */ unsigned int& countInstances, 
-                                  /* out */ uint64_t& dicomDiskSize, 
-                                  /* out */ uint64_t& dicomUncompressedSize, 
-                                  const std::string& publicId)
-  {
-    boost::mutex::scoped_lock lock(mutex_);
-
-    ResourceType type;
-    int64_t top;
-    if (!db_.LookupResource(top, type, publicId))
-    {
-      throw OrthancException(ErrorCode_UnknownResource);
-    }
-
-    GetStatisticsInternal(diskSize, uncompressedSize, countStudies, 
-                          countSeries, countInstances, dicomDiskSize,
-                          dicomUncompressedSize, top, type);    
-  }
-
-
   void ServerIndex::UnstableResourcesMonitorThread(ServerIndex* that,
                                                    unsigned int threadSleep)
   {
--- a/OrthancServer/ServerIndex.h	Tue Jan 29 10:34:00 2019 +0100
+++ b/OrthancServer/ServerIndex.h	Tue Jan 29 15:15:48 2019 +0100
@@ -97,16 +97,6 @@
                         Orthanc::ResourceType type,
                         const std::string& publicId);
 
-    void GetStatisticsInternal(/* out */ uint64_t& diskSize, 
-                               /* out */ uint64_t& uncompressedSize, 
-                               /* out */ unsigned int& countStudies, 
-                               /* out */ unsigned int& countSeries, 
-                               /* out */ unsigned int& countInstances, 
-                               /* out */ uint64_t& dicomDiskSize, 
-                               /* out */ uint64_t& dicomUncompressedSize, 
-                               /* in  */ int64_t id,
-                               /* in  */ ResourceType type);
-
     bool GetMetadataAsInteger(int64_t& result,
                               int64_t id,
                               MetadataType type);
@@ -161,7 +151,12 @@
                       DicomInstanceToStore& instance,
                       const Attachments& attachments);
 
-    void ComputeStatistics(Json::Value& target);                        
+    void GetGlobalStatistics(/* out */ uint64_t& diskSize,
+                             /* out */ uint64_t& uncompressedSize,
+                             /* out */ uint64_t& countPatients, 
+                             /* out */ uint64_t& countStudies, 
+                             /* out */ uint64_t& countSeries, 
+                             /* out */ uint64_t& countInstances);
 
     bool LookupResource(Json::Value& result,
                         const std::string& publicId,
@@ -242,17 +237,15 @@
 
     void DeleteExportedResources();
 
-    void GetStatistics(Json::Value& target,
-                       const std::string& publicId);
-
-    void GetStatistics(/* out */ uint64_t& diskSize, 
-                       /* out */ uint64_t& uncompressedSize, 
-                       /* out */ unsigned int& countStudies, 
-                       /* out */ unsigned int& countSeries, 
-                       /* out */ unsigned int& countInstances, 
-                       /* out */ uint64_t& dicomDiskSize, 
-                       /* out */ uint64_t& dicomUncompressedSize, 
-                       const std::string& publicId);
+    void GetResourceStatistics(/* out */ ResourceType& type,
+                               /* out */ uint64_t& diskSize, 
+                               /* out */ uint64_t& uncompressedSize, 
+                               /* out */ unsigned int& countStudies, 
+                               /* out */ unsigned int& countSeries, 
+                               /* out */ unsigned int& countInstances, 
+                               /* out */ uint64_t& dicomDiskSize, 
+                               /* out */ uint64_t& dicomUncompressedSize, 
+                               const std::string& publicId);
 
     void LookupIdentifierExact(std::vector<std::string>& result,
                                ResourceType level,
--- a/Resources/CMake/OrthancFrameworkConfiguration.cmake	Tue Jan 29 10:34:00 2019 +0100
+++ b/Resources/CMake/OrthancFrameworkConfiguration.cmake	Tue Jan 29 15:15:48 2019 +0100
@@ -532,6 +532,7 @@
   list(APPEND ORTHANC_CORE_SOURCES_INTERNAL
     ${ORTHANC_ROOT}/Core/Cache/SharedArchive.cpp
     ${ORTHANC_ROOT}/Core/FileStorage/FilesystemStorage.cpp
+    ${ORTHANC_ROOT}/Core/MetricsRegistry.cpp
     ${ORTHANC_ROOT}/Core/MultiThreading/RunnableWorkersPool.cpp
     ${ORTHANC_ROOT}/Core/MultiThreading/Semaphore.cpp
     ${ORTHANC_ROOT}/Core/MultiThreading/SharedMessageQueue.cpp
--- a/Resources/CMake/OrthancFrameworkParameters.cmake	Tue Jan 29 10:34:00 2019 +0100
+++ b/Resources/CMake/OrthancFrameworkParameters.cmake	Tue Jan 29 15:15:48 2019 +0100
@@ -17,7 +17,7 @@
 # Version of the Orthanc API, can be retrieved from "/system" URI in
 # order to check whether new URI endpoints are available even if using
 # the mainline version of Orthanc
-set(ORTHANC_API_VERSION "1.3")
+set(ORTHANC_API_VERSION "1.4")
 
 
 #####################################################################
--- a/Resources/Configuration.json	Tue Jan 29 10:34:00 2019 +0100
+++ b/Resources/Configuration.json	Tue Jan 29 15:15:48 2019 +0100
@@ -476,5 +476,11 @@
   // answers, but not to filter the DICOM resources (balance between
   // the two modes). By default, the mode is "Always", which
   // corresponds to the behavior of Orthanc <= 1.5.0.
-  "StorageAccessOnFind" : "Always"
+  "StorageAccessOnFind" : "Always",
+
+  // Whether Orthanc monitors its metrics (new in Orthanc 1.5.4). If
+  // set to "true", the metrics can be retrieved at
+  // "/tools/metrics-prometheus" formetted using the Prometheus
+  // text-based exposition format.
+  "MetricsEnabled" : true
 }
--- a/UnitTestsSources/ServerIndexTests.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/UnitTestsSources/ServerIndexTests.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -702,10 +702,12 @@
 
   index.SetMaximumStorageSize(10);
 
-  Json::Value tmp;
-  index.ComputeStatistics(tmp);
-  ASSERT_EQ(0, tmp["CountPatients"].asInt());
-  ASSERT_EQ(0, boost::lexical_cast<int>(tmp["TotalDiskSize"].asString()));
+  uint64_t diskSize, uncompressedSize, countPatients, countStudies, countSeries, countInstances;
+  index.GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                            countStudies, countSeries, countInstances);
+
+  ASSERT_EQ(0u, countPatients);
+  ASSERT_EQ(0u, diskSize);
 
   ServerIndex::Attachments attachments;
 
@@ -747,17 +749,19 @@
     ASSERT_EQ(hasher.HashInstance(), toStore.GetHasher().HashInstance());
   }
 
-  index.ComputeStatistics(tmp);
-  ASSERT_EQ(10, tmp["CountPatients"].asInt());
-  ASSERT_EQ(0, boost::lexical_cast<int>(tmp["TotalDiskSize"].asString()));
+  index.GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                            countStudies, countSeries, countInstances);
+  ASSERT_EQ(10u, countPatients);
+  ASSERT_EQ(0u, diskSize);
 
   for (size_t i = 0; i < ids.size(); i++)
   {
     FileInfo info(Toolbox::GenerateUuid(), FileContentType_Dicom, 1, "md5");
     index.AddAttachment(info, ids[i]);
 
-    index.ComputeStatistics(tmp);
-    ASSERT_GE(10, boost::lexical_cast<int>(tmp["TotalDiskSize"].asString()));
+    index.GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                              countStudies, countSeries, countInstances);
+    ASSERT_GE(10u, diskSize);
   }
 
   // Because the DB is in memory, the SQLite index must not have been created
@@ -800,10 +804,12 @@
     std::string id = hasher.HashInstance();
     context.GetIndex().SetOverwriteInstances(overwrite);
 
-    Json::Value tmp;
-    context.GetIndex().ComputeStatistics(tmp);
-    ASSERT_EQ(0, tmp["CountInstances"].asInt());
-    ASSERT_EQ(0, boost::lexical_cast<int>(tmp["TotalDiskSize"].asString()));
+    uint64_t diskSize, uncompressedSize, countPatients, countStudies, countSeries, countInstances;
+    context.GetIndex().GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                                           countStudies, countSeries, countInstances);
+
+    ASSERT_EQ(0, countInstances);
+    ASSERT_EQ(0, diskSize);
 
     {
       DicomInstanceToStore toStore;
@@ -820,13 +826,13 @@
     ASSERT_TRUE(context.GetIndex().LookupAttachment(dicom1, id, FileContentType_Dicom));
     ASSERT_TRUE(context.GetIndex().LookupAttachment(json1, id, FileContentType_DicomAsJson));
 
-    context.GetIndex().ComputeStatistics(tmp);
-    ASSERT_EQ(1, tmp["CountInstances"].asInt());
-    ASSERT_EQ(dicom1.GetCompressedSize() + json1.GetCompressedSize(),
-              boost::lexical_cast<size_t>(tmp["TotalDiskSize"].asString()));
-    ASSERT_EQ(dicom1.GetUncompressedSize() + json1.GetUncompressedSize(),
-              boost::lexical_cast<size_t>(tmp["TotalUncompressedSize"].asString()));
+    context.GetIndex().GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                                           countStudies, countSeries, countInstances);
+    ASSERT_EQ(1u, countInstances);
+    ASSERT_EQ(dicom1.GetCompressedSize() + json1.GetCompressedSize(), diskSize);
+    ASSERT_EQ(dicom1.GetUncompressedSize() + json1.GetUncompressedSize(), uncompressedSize);
 
+    Json::Value tmp;
     context.ReadDicomAsJson(tmp, id);
     ASSERT_EQ("name", tmp["0010,0010"]["Value"].asString());
     
@@ -855,12 +861,11 @@
     ASSERT_TRUE(context.GetIndex().LookupAttachment(dicom2, id, FileContentType_Dicom));
     ASSERT_TRUE(context.GetIndex().LookupAttachment(json2, id, FileContentType_DicomAsJson));
 
-    context.GetIndex().ComputeStatistics(tmp);
-    ASSERT_EQ(1, tmp["CountInstances"].asInt());
-    ASSERT_EQ(dicom2.GetCompressedSize() + json2.GetCompressedSize(),
-              boost::lexical_cast<size_t>(tmp["TotalDiskSize"].asString()));
-    ASSERT_EQ(dicom2.GetUncompressedSize() + json2.GetUncompressedSize(),
-              boost::lexical_cast<size_t>(tmp["TotalUncompressedSize"].asString()));
+    context.GetIndex().GetGlobalStatistics(diskSize, uncompressedSize, countPatients, 
+                                           countStudies, countSeries, countInstances);
+    ASSERT_EQ(1, countInstances);
+    ASSERT_EQ(dicom2.GetCompressedSize() + json2.GetCompressedSize(), diskSize);
+    ASSERT_EQ(dicom2.GetUncompressedSize() + json2.GetUncompressedSize(), uncompressedSize);
 
     if (overwrite)
     {
--- a/UnitTestsSources/UnitTestsMain.cpp	Tue Jan 29 10:34:00 2019 +0100
+++ b/UnitTestsSources/UnitTestsMain.cpp	Tue Jan 29 15:15:48 2019 +0100
@@ -41,6 +41,7 @@
 #include "../Core/DicomFormat/DicomTag.h"
 #include "../Core/HttpServer/HttpToolbox.h"
 #include "../Core/Logging.h"
+#include "../Core/MetricsRegistry.h"
 #include "../Core/OrthancException.h"
 #include "../Core/TemporaryFile.h"
 #include "../Core/Toolbox.h"
@@ -1236,6 +1237,120 @@
 }
 
 
+TEST(MetricsRegistry, Basic)
+{
+  {
+    MetricsRegistry m;
+    m.SetEnabled(false);
+    m.SetValue("hello.world", 42.5f);
+    
+    std::string s;
+    m.ExportPrometheusText(s);
+    ASSERT_TRUE(s.empty());
+  }
+
+  {
+    MetricsRegistry m;
+    m.Register("hello.world", MetricsType_Default);
+    
+    std::string s;
+    m.ExportPrometheusText(s);
+    ASSERT_TRUE(s.empty());
+  }
+
+  {
+    MetricsRegistry m;
+    m.SetValue("hello.world", 42.5f);
+    ASSERT_EQ(MetricsType_Default, m.GetMetricsType("hello.world"));
+    ASSERT_THROW(m.GetMetricsType("nope"), OrthancException);
+    
+    std::string s;
+    m.ExportPrometheusText(s);
+
+    std::vector<std::string> t;
+    Toolbox::TokenizeString(t, s, '\n');
+    ASSERT_EQ(2u, t.size());
+    ASSERT_EQ("hello.world 42.5 ", t[0].substr(0, 17));
+    ASSERT_TRUE(t[1].empty());
+  }
+
+  {
+    MetricsRegistry m;
+    m.Register("hello.max", MetricsType_MaxOver10Seconds);
+    m.SetValue("hello.max", 10);
+    m.SetValue("hello.max", 20);
+    m.SetValue("hello.max", -10);
+    m.SetValue("hello.max", 5);
+
+    m.Register("hello.min", MetricsType_MinOver10Seconds);
+    m.SetValue("hello.min", 10);
+    m.SetValue("hello.min", 20);
+    m.SetValue("hello.min", -10);
+    m.SetValue("hello.min", 5);
+    
+    m.Register("hello.default", MetricsType_Default);
+    m.SetValue("hello.default", 10);
+    m.SetValue("hello.default", 20);
+    m.SetValue("hello.default", -10);
+    m.SetValue("hello.default", 5);
+    
+    ASSERT_EQ(MetricsType_MaxOver10Seconds, m.GetMetricsType("hello.max"));
+    ASSERT_EQ(MetricsType_MinOver10Seconds, m.GetMetricsType("hello.min"));
+    ASSERT_EQ(MetricsType_Default, m.GetMetricsType("hello.default"));
+
+    std::string s;
+    m.ExportPrometheusText(s);
+
+    std::vector<std::string> t;
+    Toolbox::TokenizeString(t, s, '\n');
+    ASSERT_EQ(4u, t.size());
+    ASSERT_TRUE(t[3].empty());
+
+    std::map<std::string, std::string> u;
+    for (size_t i = 0; i < t.size() - 1; i++)
+    {
+      std::vector<std::string> v;
+      Toolbox::TokenizeString(v, t[i], ' ');
+      u[v[0]] = v[1];
+    }
+
+    ASSERT_EQ("20", u["hello.max"]);
+    ASSERT_EQ("-10", u["hello.min"]);
+    ASSERT_EQ("5", u["hello.default"]);
+  }
+
+  {
+    MetricsRegistry m;
+
+    m.SetValue("a", 10);
+    m.SetValue("b", 10, MetricsType_MinOver10Seconds);
+
+    m.Register("c", MetricsType_MaxOver10Seconds);
+    m.SetValue("c", 10, MetricsType_MinOver10Seconds);
+
+    m.Register("d", MetricsType_MaxOver10Seconds);
+    m.Register("d", MetricsType_Default);
+
+    ASSERT_EQ(MetricsType_Default, m.GetMetricsType("a"));
+    ASSERT_EQ(MetricsType_MinOver10Seconds, m.GetMetricsType("b"));
+    ASSERT_EQ(MetricsType_MaxOver10Seconds, m.GetMetricsType("c"));
+    ASSERT_EQ(MetricsType_Default, m.GetMetricsType("d"));
+  }
+
+  {
+    MetricsRegistry m;
+
+    {
+      MetricsRegistry::Timer t1(m, "a");
+      MetricsRegistry::Timer t2(m, "b", MetricsType_MinOver10Seconds);
+    }
+
+    ASSERT_EQ(MetricsType_MaxOver10Seconds, m.GetMetricsType("a"));
+    ASSERT_EQ(MetricsType_MinOver10Seconds, m.GetMetricsType("b"));
+  }
+}
+
+
 int main(int argc, char **argv)
 {
   Logging::Initialize();