diff --git a/Event/LumiEvent/CMakeLists.txt b/Event/LumiEvent/CMakeLists.txt
index c2317d50099f747473e34b4b5a69073b85257953..c37ebad0e8752e5bdfae4d9dcd738699a82203b5 100644
--- a/Event/LumiEvent/CMakeLists.txt
+++ b/Event/LumiEvent/CMakeLists.txt
@@ -16,6 +16,8 @@ Event/LumiEvent
 gaudi_add_header_only_library(LumiEventLib
     LINK
         Gaudi::GaudiKernel
+        LHCb::LHCbKernel
+        nlohmann_json::nlohmann_json
 )
 
 gaudi_add_dictionary(LumiEventDict
@@ -23,3 +25,14 @@ gaudi_add_dictionary(LumiEventDict
     SELECTION dict/selection.xml
     LINK LHCb::LumiEventLib
 )
+
+if(BUILD_TESTING)
+    gaudi_add_executable(test_LumiEventCounter
+        SOURCES
+            tests/src/test_LumiEventCounter.cpp
+        LINK
+            Boost::unit_test_framework
+            LumiEventLib
+        TEST
+    )
+endif()
diff --git a/Event/LumiEvent/include/Event/LumiEventCounter.h b/Event/LumiEvent/include/Event/LumiEventCounter.h
new file mode 100644
index 0000000000000000000000000000000000000000..51cbf0e9fede9ee4c1f3ba328fb240c6f6ad1368
--- /dev/null
+++ b/Event/LumiEvent/include/Event/LumiEventCounter.h
@@ -0,0 +1,97 @@
+/*****************************************************************************\
+* (c) Copyright 2023 CERN for the benefit of the LHCb Collaboration           *
+*                                                                             *
+* This software is distributed under the terms of the GNU General Public      *
+* Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING".   *
+*                                                                             *
+* In applying this licence, CERN does not waive the privileges and immunities *
+* granted to it by virtue of its status as an Intergovernmental Organization  *
+* or submit itself to any jurisdiction.                                       *
+\*****************************************************************************/
+#pragma once
+
+#include <Gaudi/MonitoringHub.h>
+#include <Kernel/SynchronizedValue.h>
+#include <algorithm>
+#include <cstdint>
+#include <map>
+#include <nlohmann/json.hpp>
+#include <string>
+#include <utility>
+
+namespace LHCb {
+  /// Special counter to split counts of events by run.
+  ///
+  /// A LumiEventCounter instance can be used as any Gaudi::Accumulator::Counter. To increment the counter
+  /// for a given run number one has to invoke the method LumiEventCounter::inc(std::uint32_t) passing the
+  /// current run number and optionally the increase step (by default it's 1).
+  ///
+  /// Example of use:
+  /// ```cpp
+  /// #include <Event/LumiEventCounter.h>
+  /// #include <LHCbAlgs/Consumer.h>
+  /// #include <Event/ODIN.h>
+  /// struct LumiCountAlg final : public LHCb::Algorithm::Consumer<void( const LHCb::ODIN& )> {
+  ///   LumiCountAlg( const std::string& name, ISvcLocator* pSvcLocator )
+  ///       : Consumer( name, pSvcLocator, KeyValue{"ODIN", ODINLocation::Default} ) {}
+  ///   void operator()( const LHCb::ODIN& odin ) const override { m_lumiCount.inc( odin.runNumber() ); }
+  ///
+  ///   mutable LHCb::LumiEventCounter m_lumiCount{this, "eventsByRun"};
+  /// };
+  /// ```
+  class LumiEventCounter final {
+  public:
+    LumiEventCounter() = default;
+
+    template <typename OWNER>
+    LumiEventCounter( OWNER* o, std::string name ) : m_monitoringHub{&o->serviceLocator()->monitoringHub()} {
+      m_monitoringHub->registerEntity( o->name(), std::move( name ), "LumiEventCounter", *this );
+    }
+
+    ~LumiEventCounter() {
+      if ( m_monitoringHub ) m_monitoringHub->removeEntity( *this );
+    }
+
+    nlohmann::json toJSON() const { return *this; }
+    void           reset() {
+      m_counts.with_lock( []( map_type& counts ) { counts.clear(); } );
+    }
+
+    void inc( std::uint32_t run, std::size_t count = 1 ) {
+      m_counts.with_lock( [run, count]( map_type& counts ) { counts[run] += count; } );
+    }
+
+    std::size_t get( std::uint32_t run ) const {
+      return m_counts.with_lock( [run]( auto& counts ) -> std::size_t {
+        if ( auto it = counts.find( run ); it != counts.end() ) {
+          return it->second;
+        } else {
+          return 0;
+        }
+      } );
+    }
+
+    bool empty() const {
+      return m_counts.with_lock( []( auto& counts ) {
+        return std::all_of( counts.begin(), counts.end(), []( const auto& item ) { return item.second == 0; } );
+      } );
+    }
+
+  private:
+    using map_type = std::map<std::uint32_t, std::size_t>;
+    cxx::SynchronizedValue<map_type> m_counts;
+
+    Gaudi::Monitoring::Hub* m_monitoringHub{nullptr};
+    friend void             to_json( nlohmann::json&, const LumiEventCounter& );
+  };
+
+  void to_json( nlohmann::json& j, const LumiEventCounter& c ) {
+    j["type"]   = "LumiEventCounter";
+    j["empty"]  = c.empty();
+    j["counts"] = c.m_counts.with_lock( []( auto& counts ) {
+      auto tmp = nlohmann::json::object();
+      for ( const auto [run, count] : counts ) { tmp.emplace( std::to_string( run ), count ); }
+      return tmp;
+    } );
+  }
+} // namespace LHCb
diff --git a/Event/LumiEvent/tests/src/test_LumiEventCounter.cpp b/Event/LumiEvent/tests/src/test_LumiEventCounter.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0da4bf35c72a9b147ca3393510c41780aa9234fc
--- /dev/null
+++ b/Event/LumiEvent/tests/src/test_LumiEventCounter.cpp
@@ -0,0 +1,129 @@
+/*****************************************************************************\
+* (c) Copyright 2023 CERN for the benefit of the LHCb Collaboration           *
+*                                                                             *
+* This software is distributed under the terms of the GNU General Public      *
+* Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING".   *
+*                                                                             *
+* In applying this licence, CERN does not waive the privileges and immunities *
+* granted to it by virtue of its status as an Intergovernmental Organization  *
+* or submit itself to any jurisdiction.                                       *
+\*****************************************************************************/
+#include <Event/LumiEventCounter.h>
+
+#define BOOST_TEST_MODULE test_LumiEventCounter
+#define BOOST_TEST_DYN_LINK
+#include <boost/test/unit_test.hpp>
+
+#include <Gaudi/MonitoringHub.h>
+#include <algorithm>
+#include <utility>
+#include <vector>
+
+using LC = LHCb::LumiEventCounter;
+
+BOOST_AUTO_TEST_CASE( empty ) {
+  LC cnt;
+
+  BOOST_TEST( cnt.empty() );
+  BOOST_TEST( cnt.get( 1000 ) == 0 );
+  BOOST_TEST( cnt.empty() );
+}
+
+BOOST_AUTO_TEST_CASE( filling ) {
+  LC cnt;
+
+  cnt.inc( 1000 );
+  BOOST_TEST( !cnt.empty() );
+  BOOST_TEST( cnt.get( 1000 ) == 1 );
+
+  cnt.inc( 1000 );
+  cnt.inc( 1000 );
+  BOOST_TEST( cnt.get( 1000 ) == 3 );
+
+  cnt.inc( 2002, 100 );
+  BOOST_TEST( cnt.get( 1000 ) == 3 );
+  BOOST_TEST( cnt.get( 2002 ) == 100 );
+
+  BOOST_TEST( cnt.get( 3001 ) == 0 );
+  cnt.inc( 3001 );
+  BOOST_TEST( cnt.get( 3001 ) == 1 );
+
+  BOOST_TEST( !cnt.empty() );
+}
+
+BOOST_AUTO_TEST_CASE( reset ) {
+  LC cnt;
+
+  BOOST_TEST( cnt.empty() );
+  cnt.inc( 1000 );
+  BOOST_TEST( !cnt.empty() );
+  BOOST_TEST( cnt.get( 1000 ) == 1 );
+  cnt.reset();
+  BOOST_TEST( cnt.empty() );
+}
+
+BOOST_AUTO_TEST_CASE( to_json ) {
+  LC cnt;
+
+  nlohmann::json j = cnt;
+  BOOST_TEST( j["type"].get<std::string>() == "LumiEventCounter" );
+  BOOST_TEST( j["empty"].get<bool>() );
+  BOOST_TEST( j["counts"].empty() );
+
+  cnt.inc( 1000 );
+  cnt.inc( 2000, 5 );
+  j = cnt;
+  BOOST_TEST( !j["empty"].get<bool>() );
+  BOOST_TEST( j["counts"].size() == 2 );
+  BOOST_TEST( j["counts"]["1000"].get<std::size_t>() == 1 );
+  BOOST_TEST( j["counts"]["2000"].get<std::size_t>() == 5 );
+}
+
+BOOST_AUTO_TEST_CASE( monitoring ) {
+  using Gaudi::Monitoring::Hub;
+  using Entity = Gaudi::Monitoring::Hub::Entity;
+  using Sink   = Gaudi::Monitoring::Hub::Sink;
+  // Mock code for the test
+  struct MonitoringHub : Hub {};
+  struct ServiceLocator {
+    MonitoringHub& monitoringHub() { return m_monitHub; }
+    MonitoringHub  m_monitHub{};
+  };
+  struct Component {
+    ServiceLocator* serviceLocator() { return &m_serviceLocator; }
+    std::string     name() const { return "owner"; }
+    ServiceLocator  m_serviceLocator{};
+  } alg;
+  struct MySink : Sink {
+    void registerEntity( Entity ent ) override { entities.emplace_back( std::move( ent ) ); }
+    void removeEntity( Entity const& ent ) override {
+      auto it = std::remove( entities.begin(), entities.end(), ent );
+      entities.erase( it, entities.end() );
+    }
+    std::vector<Entity> entities;
+    Hub&                hub;
+    MySink( Hub& h ) : hub{h} { hub.addSink( this ); }
+    ~MySink() { hub.removeSink( this ); }
+  };
+  MySink sink( alg.serviceLocator()->monitoringHub() );
+
+  BOOST_TEST( sink.entities.empty() );
+
+  {
+    LC cnt{&alg, "lumi_cnt"};
+    BOOST_TEST( sink.entities.size() == 1 );
+
+    auto j = sink.entities[0].toJSON();
+    BOOST_TEST( j["type"].get<std::string>() == "LumiEventCounter" );
+    BOOST_TEST( j["empty"].get<bool>() );
+    BOOST_TEST( j["counts"].empty() );
+
+    cnt.inc( 1000 );
+    j = sink.entities[0].toJSON();
+    BOOST_TEST( !j["empty"].get<bool>() );
+    BOOST_TEST( j["counts"].size() == 1 );
+    BOOST_TEST( j["counts"]["1000"].get<std::size_t>() == 1 );
+  }
+
+  BOOST_TEST( sink.entities.empty() );
+}
diff --git a/Kernel/FileSummaryRecord/CMakeLists.txt b/Kernel/FileSummaryRecord/CMakeLists.txt
index 914590e7d8c1a724ba62bcdbe2624c14487e0a76..92bc1075bebebfb61df9bb3a056b647d16e230b9 100644
--- a/Kernel/FileSummaryRecord/CMakeLists.txt
+++ b/Kernel/FileSummaryRecord/CMakeLists.txt
@@ -50,7 +50,9 @@ if(BUILD_TESTING)
             tests/src/FSRTestAlgs.cpp
         LINK
             Gaudi::GaudiKernel
+            LHCb::DAQEventLib
             LHCb::LHCbAlgsLib
+            LHCb::LumiEventLib
     )
 
     lhcb_env(PRIVATE PREPEND PYTHONPATH ${CMAKE_CURRENT_SOURCE_DIR}/tests/python)
diff --git a/Kernel/FileSummaryRecord/tests/python/FSRTests/write_lumi.py b/Kernel/FileSummaryRecord/tests/python/FSRTests/write_lumi.py
new file mode 100644
index 0000000000000000000000000000000000000000..97bacdea5f411af59ab4e514d04103ccdd35bf22
--- /dev/null
+++ b/Kernel/FileSummaryRecord/tests/python/FSRTests/write_lumi.py
@@ -0,0 +1,143 @@
+###############################################################################
+# (c) Copyright 2023 CERN for the benefit of the LHCb Collaboration           #
+#                                                                             #
+# This software is distributed under the terms of the GNU General Public      #
+# Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING".   #
+#                                                                             #
+# In applying this licence, CERN does not waive the privileges and immunities #
+# granted to it by virtue of its status as an Intergovernmental Organization  #
+# or submit itself to any jurisdiction.                                       #
+###############################################################################
+import os
+import json
+from traceback import format_exc
+from unittest import TestCase
+from pprint import pformat
+
+FILENAMEFSR = f"{__name__}.fsr.json"
+FILENAME = f"{__name__}.root"
+FILENAMEJSON = f"{__name__}.json"
+
+
+def checkDiff(a, b):
+    try:
+        TestCase().assertEqual(a, b)
+    except AssertionError as err:
+        return str(err)
+
+
+def config():
+    from PyConf.application import configure, configure_input, ApplicationOptions, root_writer
+    from PyConf.control_flow import CompositeNode
+    from PyConf.components import setup_component
+    from PyConf.Algorithms import LHCb__Tests__RunEventCountAlg, Gaudi__Examples__IntDataProducer, LHCb__Tests__FakeRunNumberProducer
+    from Configurables import ApplicationMgr
+
+    options = ApplicationOptions(_enabled=False)
+    # No data from the input is used, but something should be there for the configuration
+    options.input_files = ["dummy_input_file_name.dst"]
+    options.input_type = 'ROOT'
+    options.output_file = FILENAME
+    options.output_type = 'ROOT'
+    options.data_type = 'Upgrade'
+    options.dddb_tag = 'upgrade/dddb-20220705'
+    options.conddb_tag = 'upgrade/sim-20220705-vc-mu100'
+    options.geometry_version = 'trunk'
+    options.conditions_version = 'master'
+    options.simulation = True
+    options.evt_max = 5
+    options.monitoring_file = FILENAMEJSON
+    # options.output_level = 2
+
+    config = configure_input(options)
+
+    app = ApplicationMgr()
+    app.EvtSel = "NONE"  # ignore input configuration
+    app.ExtSvc.append(
+        config.add(
+            setup_component(
+                "LHCb__FSR__Sink",
+                instance_name="FileSummaryRecord",
+                AcceptRegex=r"^LumiCounter\.eventsByRun$",
+                OutputFile=FILENAMEFSR,
+            )))
+
+    producer = Gaudi__Examples__IntDataProducer()
+    odin_producer = LHCb__Tests__FakeRunNumberProducer()
+
+    cf = CompositeNode("test", [
+        odin_producer,
+        LHCb__Tests__RunEventCountAlg(
+            name="LumiCounter", ODIN=odin_producer.ODIN),
+        producer,
+        root_writer(options.output_file, [producer.OutputLocation]),
+    ])
+    app.OutStream.clear()
+    config.update(configure(options, cf))
+
+    # make sure the histogram file is not already there
+    for name in [FILENAME, FILENAMEFSR, FILENAMEJSON]:
+        if os.path.exists(name):
+            os.remove(name)
+
+
+def check(causes, result):
+    result["root_output_file"] = FILENAME
+
+    missing_files = [
+        name for name in [FILENAME, FILENAMEFSR, FILENAMEJSON]
+        if not os.path.exists(name)
+    ]
+    if missing_files:
+        causes.append("missing output file(s)")
+        result["missing_files"] = ", ".join(missing_files)
+        return False
+
+    expected = {
+        "LumiCounter.eventsByRun": {
+            "counts": {
+                "1": 1,
+                "2": 1,
+                "3": 1,
+                "4": 1,
+                "5": 1
+            },
+            "empty": False,
+            "type": "LumiEventCounter"
+        }
+    }
+
+    try:
+        import ROOT
+
+        fsr_dump = json.load(open(FILENAMEFSR))
+
+        f = ROOT.TFile.Open(FILENAME)
+        fsr_root = json.loads(str(f.FileSummaryRecord))
+
+    except Exception as err:
+        causes.append("failure in root file")
+        result["python_exception"] = result.Quote(format_exc())
+        return False
+
+    result["FSR"] = result.Quote(pformat(fsr_dump))
+
+    checking = "no check yet"
+    try:
+        guid = fsr_dump.get("guid")
+        assert guid, "missing or invalid GUID in FSR dump"
+
+        expected["guid"] = guid  # GUID is random
+
+        tester = TestCase()
+        checking = "JSON dump"
+        tester.assertEqual(expected, fsr_dump)
+        checking = "ROOT file"
+        tester.assertEqual(expected, fsr_root)
+
+    except AssertionError as err:
+        causes.append("FSR content")
+        result[f"FSR problem ({checking})"] = result.Quote(str(err))
+        return False
+
+    return True
diff --git a/Kernel/FileSummaryRecord/tests/qmtest/filesummaryrecord.qms/write_lumi.qmt b/Kernel/FileSummaryRecord/tests/qmtest/filesummaryrecord.qms/write_lumi.qmt
new file mode 100644
index 0000000000000000000000000000000000000000..e00492c5e7c51b2876a7285348dfbcb66f7ddaec
--- /dev/null
+++ b/Kernel/FileSummaryRecord/tests/qmtest/filesummaryrecord.qms/write_lumi.qmt
@@ -0,0 +1,20 @@
+<?xml version="1.0" ?><!DOCTYPE extension  PUBLIC '-//QM/2.3/Extension//EN'  'http://www.codesourcery.com/qm/dtds/2.3/-//qm/2.3/extension//en.dtd'>
+<!--
+    (c) Copyright 2023 CERN for the benefit of the LHCb Collaboration
+
+    This software is distributed under the terms of the GNU General Public
+    Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING".
+
+    In applying this licence, CERN does not waive the privileges and immunities
+    granted to it by virtue of its status as an Intergovernmental Organization
+    or submit itself to any jurisdiction.
+-->
+<extension class="GaudiTest.GaudiExeTest" kind="test">
+<argument name="program"><text>gaudirun.py</text></argument>
+<argument name="args"><set><text>FSRTests.write_lumi:config</text></set></argument>
+<argument name="use_temp_dir"><enumeral>true</enumeral></argument>
+<argument name="validator"><text>
+from FSRTests.write_lumi import check
+check(causes, result)
+</text></argument>
+</extension>
diff --git a/Kernel/FileSummaryRecord/tests/src/FSRTestAlgs.cpp b/Kernel/FileSummaryRecord/tests/src/FSRTestAlgs.cpp
index bcd8db720474f97cd111c3afeb57a483c3f60002..51aef568f8f57784bd7d7253890e5c851b655f62 100644
--- a/Kernel/FileSummaryRecord/tests/src/FSRTestAlgs.cpp
+++ b/Kernel/FileSummaryRecord/tests/src/FSRTestAlgs.cpp
@@ -1,5 +1,5 @@
 /*****************************************************************************\
-* (c) Copyright 2022 CERN for the benefit of the LHCb Collaboration           *
+* (c) Copyright 2022-2023 CERN for the benefit of the LHCb Collaboration      *
 *                                                                             *
 * This software is distributed under the terms of the GNU General Public      *
 * Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING".   *
@@ -8,6 +8,8 @@
 * granted to it by virtue of its status as an Intergovernmental Organization  *
 * or submit itself to any jurisdiction.                                       *
 \*****************************************************************************/
+#include <Event/LumiEventCounter.h>
+#include <Event/ODIN.h>
 #include <Gaudi/Accumulators.h>
 #include <LHCbAlgs/Consumer.h>
 
@@ -20,4 +22,14 @@ namespace LHCb ::Tests {
     mutable Gaudi::Accumulators::Counter<> m_events{this, "count"};
   };
   DECLARE_COMPONENT( EventCountAlg )
+
+  /// Simple algorithm that counts how many times it is invoked per run
+  struct RunEventCountAlg final : public LHCb::Algorithm::Consumer<void( const LHCb::ODIN& )> {
+    RunEventCountAlg( const std::string& name, ISvcLocator* pSvcLocator )
+        : Consumer( name, pSvcLocator, KeyValue{"ODIN", ODINLocation::Default} ) {}
+    void operator()( const LHCb::ODIN& odin ) const override { m_lumiCount.inc( odin.runNumber() ); }
+
+    mutable LHCb::LumiEventCounter m_lumiCount{this, "eventsByRun"};
+  };
+  DECLARE_COMPONENT( RunEventCountAlg )
 } // namespace LHCb::Tests