A client-independent Prometheus / OpenMetrics metric abstraction for C++23.
prom lets you declare and record Prometheus-style metrics (Counter, Gauge,
Histogram, Summary, Untyped, Info, StateSet) without committing to a
concrete metrics client. Your code records samples through prom's small,
stable API. A separate adapter decides where those samples actually go. Until
an application installs a real backend, every metric resolves to a built-in
NullAdapter that turns each operation into a safe, logged no-op — so code that
records metrics runs unchanged whether or not a backend is present.
This is the headline use case: a reusable library can ship metric instrumentation without forcing a Prometheus client dependency on everyone who links it. The host application installs a backend once, at startup.
#include <prom/prom.hpp>
// In a reusable library — no backend dependency, no registry to thread around:
auto requests = prom::counter({.name = "mylib_requests_total",
.help = "Requests handled by mylib"});
requests.inc(); // safe no-op until the application installs a backendA C++ library that wants to expose operational metrics usually faces a choice:
hard-depend on a specific Prometheus client (and impose it on every downstream
user), or expose nothing. prom removes that choice.
- Good for instrumenting a library that should not dictate its consumers' metrics stack.
- Good for application code that wants a one-line
prom::counter(...)API and the freedom to wire (or not wire) a backend later. - Avoids leaking any backend type across the API boundary — the core never includes a prometheus-cpp header.
- Useful when you want metrics that are always safe to call, even before (or without) any exporter installed.
- Useful when you want to record dimensional quantities (seconds, bytes/s, hertz) and have the unit carried along automatically.
- Not ideal for a standalone application that already commits to one client and wants its native API directly — the indirection buys you nothing there.
- Not ideal for scrape/exposition itself:
promrecords samples; serving/metricsis the backend adapter's job (see Enabling a real backend).
The smallest useful program. It records a few samples; with no backend
installed they go to the NullAdapter.
#include <prom/prom.hpp>
#include <iostream>
int main() {
// The free helpers create metrics through the process-wide registry.
// No Registry object to construct or pass around.
auto requests = prom::counter({.name = "demo_requests_total", .help = "demo"});
auto in_flight = prom::gauge({.name = "demo_in_flight", .help = "demo"});
requests.inc(); // +1
requests.inc(10); // +10
in_flight.set(3);
in_flight.dec(); // -1
std::cout << "backend = "
<< prom::Registry::global()->adapter()->backend_name() << "\n";
return 0;
}Why it works:
prom::counter(...)/prom::gauge(...)are free functions that delegate to the process-wideRegistry::global(). You never have to create or pass a registry for the common case.- Each metric is a cheap, copyable value. It binds to whatever adapter is
installed on the global cell on first use — here, the default
NullAdapter. - Every mutation (
inc,set,dec, ...) isnoexcept. Nothing here can throw, and nothing is exported, because no real backend is installed.
Running it prints backend = null.
prom's core is header-only and requires C++23. Its three runtime
dependencies are fetched automatically by CMake when you don't already have them
installed:
- commons — display metadata
(
comms::DisplayInfo). - logman — logging (over spdlog).
- dimval — dimensional value types (the core only matches them structurally — see Dimensional values).
cmake_minimum_required(VERSION 3.25)
project(example LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(
prom
URL https://github.com/aurimasniekis/cpp-prom/archive/refs/tags/v0.1.0.tar.gz
URL_HASH SHA256=7a2cb15120646c6cb004df38911ef275a2c90f68b0ad2484ed434c3c8478a0e7
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
FetchContent_MakeAvailable(prom)
add_executable(example main.cpp)
target_link_libraries(example PRIVATE prom::prom)prom's own dependencies are each declared with FIND_PACKAGE_ARGS, so an
installed copy is preferred over fetching when present.
find_package(prom 0.1 REQUIRED) # pulls commons / logman / dimval transitively
target_link_libraries(app PRIVATE prom::prom)The real backend is an opt-in module built from source, gated by a CMake option. It is a compiled static library (not header-only):
cmake -S . -B build -DPROM_WITH_PROMETHEUS_CPP=ONtarget_link_libraries(app PRIVATE prom::prom prom::prometheus_cpp)This pulls in prometheus-cpp via
FetchContent and exposes the prom::prometheus_cpp target.
- C++ standard: C++23 (
cxx_std_23;CMAKE_CXX_EXTENSIONS OFF). - CMake: 3.25 or newer.
- Dependencies (core): commons, logman (+ spdlog), dimval — fetched automatically if not installed.
- Optional: prometheus-cpp, only when
-DPROM_WITH_PROMETHEUS_CPP=ON.
Every metric (Counter, Gauge, ...) is a small, copyable object holding a
shared_ptr to shared per-series state. Copies refer to the same series.
prom::Counter a{"shared_total", "h"}; // standalone, unbound
prom::Counter b = a; // a copy
a.inc(1);
b.inc(2); // same series — total is 3A standalone metric (constructed directly from name/help or a spec) is
unbound until first use; on the first inc/set/observe it binds to
whatever adapter the process-wide cell currently holds and registers itself.
A Registry owns the adapter its metrics record through and validates each spec
up front. You rarely need to construct one: Registry::global() is a
process-wide instance the free prom::counter(...) helpers delegate to.
auto reg = prom::Registry::global();
auto c = reg->counter({.name = "requests_total", .help = "..."});Registry is non-copyable and shared_ptr-managed: obtain one with
Registry::create(...) or Registry::global() and call it through ->. Reach
for an explicit registry when you want an independent adapter (e.g. in a test):
auto backend = std::make_shared<my::Backend>();
auto pinned = prom::Registry::create(backend); // its own adapter cell
auto c = pinned->counter({.name = "x_total", .help = "..."});A registry can also decorate every metric it creates with a shared name
prefix, default constant labels, and default display — the same thing a
Scope does, but applied at the
registry level. Pass a RegistryConfig to create:
auto reg = prom::Registry::create(backend, {.prefix = "svc_",
.const_labels = prom::Labels{{"region", "eu"}}});
auto c = reg->counter({.name = "requests_total"}); // -> svc_requests_total{region="eu"}The decoration is live: every metric the registry has created — including ones created before the change — re-registers under the new name/labels on its next use (a metric's own label still wins over a registry default on a name collision). The setters work even on a registry created without a config (its decoration simply starts empty):
reg->set_prefix("api_"); // c re-registers as api_requests_total
reg->add_const_label("az", "eu-1");A registry whose decoration is empty leaves metrics with their spec names
verbatim and reports scoped == false from metrics().
Registry::global()'s decoration is the process-wide one, shared by
standalone metrics (prom::counter(...) and direct constructors) and used as the
chain parent of every Scope. So a
prefix or label installed there reaches every metric in the process:
prom::Registry::global()->set_prefix("svc_"); // every metric gains svc_*
prom::Registry::global()->add_const_label("region", "eu");When a scope sits underneath, the two compose with the global prefix outermost —
a scope foo_ under a global reg_ yields reg_foo_meter. Label precedence is
own → scope → global.
Everything funnels through one interface, prom::Adapter, in a
register-then-mutate model: a family is registered once
(register_metric), labeled children are obtained with resolve, and samples
are pushed with inc/dec/set/observe/set_info/set_state. No
backend-specific type ever crosses this line. Every adapter method is noexcept
and may be called concurrently.
The default NullAdapter records nothing: it logs registration at debug,
mutations at trace, and is stateless and fully thread-safe.
The adapter does not live on each metric; it lives on an AdapterCell
shared by the metrics that read from it. Registry::global() and all standalone
/ scoped metrics share the process-wide cell, so installing a backend there
reconfigures everything at once:
prom::Registry::global()->set_adapter(std::make_shared<my::Backend>());
// passing nullptr resets the cell back to a fresh NullAdapterThis is what prom is for. The library declares metrics through the free
helpers and never mentions a backend:
#include <prom/prom.hpp>
namespace mylib {
class Telemetry {
public:
static Telemetry& instance() {
static Telemetry t;
return t;
}
prom::Counter requests = prom::counter(
{.name = "mylib_requests_total", .help = "Total requests handled by mylib"});
prom::Histogram latency = prom::histogram(
{.name = "mylib_request_seconds", .help = "Request latency in seconds"});
};
void handle_request(double seconds) {
Telemetry::instance().requests.inc();
Telemetry::instance().latency.observe(seconds);
}
} // namespace mylibIf the application never installs a backend, mylib still runs — the metrics
resolve to the NullAdapter. If it does, the same code starts exporting.
#include <prom/prom.hpp>
auto reg = prom::Registry::create();
auto counter = reg->counter({.name = "events_total", .help = "events"});
auto gauge = reg->gauge({.name = "queue_depth", .help = "queue depth"});
auto hist = reg->histogram({.name = "op_seconds", .help = "op latency",
.buckets = {0.1, 0.5, 1.0}});
auto summ = reg->summary({.name = "payload_bytes", .help = "payload sizes"});
auto unt = reg->untyped({.name = "external_value", .help = "raw"});
auto info = reg->info({.name = "build_info", .help = "build metadata"});
auto state = reg->stateset({.name = "service_state", .help = "lifecycle",
.states = {"starting", "running", "stopped"}});
counter.inc(); // +1
counter.inc(7); // +7
gauge.set(5);
gauge.dec(); // -1
hist.observe(0.3);
summ.observe(2048);
unt.set(-1.5); // untyped: no sign constraints
info.set({{"version", "0.1.0"}, {"commit", "deadbeef"}});
state.set("running", true); // one boolean member of the setWhat each type does:
| Type | Mutators | Notes |
|---|---|---|
Counter |
inc(), inc(v) |
Monotonic. Negative / non-finite increments are dropped + logged. |
Gauge |
set(v), inc(), inc(v), dec(), dec(v) |
Moves both directions. |
Histogram |
observe(v) |
Default buckets {.005,.01,.025,.05,.1,.25,.5,1,2.5,5,10} when unspecified. |
Summary |
observe(v) |
Default quantiles {0.5, 0.9, 0.99} when unspecified. |
Untyped |
set(v) |
No semantic constraints. |
Info |
set({{k, v}, ...}), set(Labels) |
Static key/value metadata; rendered as name_info{...} 1. |
StateSet |
set(state, bool) |
A set of related boolean states; one series per state. |
The simplest path. Any arithmetic type works — it routes through
prom::normalize() to a double:
auto bytes = reg->counter({.name = "bytes_processed_total", .help = "bytes"});
auto temp = reg->gauge({.name = "cpu_celsius", .help = "temperature"});
bytes.inc(512); // int
bytes.inc(1024U); // unsigned
temp.set(41.7); // double
temp.set(39); // int — fineA dimensional value carries its unit and dimensional kind alongside the
magnitude. prom reduces it to a {value, Unit} pair and infers the metric's
unit from the first dimensional sample it sees:
#include <prom/prom.hpp>
#include <dimval/dimval.hpp>
auto latency = reg->histogram({.name = "rpc_seconds", .help = "RPC latency"});
auto throughput = reg->gauge ({.name = "link_byte_rate", .help = "throughput"});
auto tuned = reg->gauge ({.name = "radio_center_hz", .help = "tuned freq"});
latency.observe(dimval::SecondValue{0.0123});
throughput.set(dimval::ByteRateValue{1.25e6});
tuned.set(dimval::CenterFrequencyValue{100.3e6});prom never includes a dimval header. The DimensionalValue concept matches
dimval's value types structurally (it just needs value_t,
numeric_as_double(), and a unit descriptor), so handing prom a dimensional
value does not drag dimval's concrete types into prom's own translation units.
Pitfall — unit-kind mismatch. Once a metric has latched a unit (declared or inferred), a later dimensional sample whose kind disagrees (e.g. a length value into a metric that latched
time) is dropped and logged, never thrown. A raw (unitless) sample always passes. See Edge cases.
Constant labels apply to the whole family; dynamic labels select a child series
via .labels(...):
auto requests = reg->counter({.name = "http_requests_total",
.help = "HTTP requests",
.labels = prom::Labels{{"service", "api"}}}); // constant
requests.labels(prom::Labels{{"method", "GET"}, {"code", "200"}}).inc();
requests.labels(prom::Labels{{"method", "GET"}, {"code", "200"}}).inc(); // same child
requests.labels(prom::Labels{{"method", "POST"}, {"code", "500"}}).inc();prom::Labels is kept sorted by name with duplicates collapsed (last write
wins), and caches an FNV-1a hash so it can key the backend's child cache.
Pitfall — labeled children are pinned. A child snapshots its adapter (and scope-decorated state) at the moment
labels()is called and never migrates if the adapter or scope later changes. Resolve children after the backend is installed.
A Scope is to prom what a named logger is to a logging library: one instance
per library, with a shared name prefix, default constant labels, and default
display metadata. It is registered process-wide by name.
#include <prom/scope.hpp>
auto lib = prom::scope("mylib", {.prefix = "mylib_",
.const_labels = prom::Labels{{"component", "io"}}});
auto c = lib->counter({.name = "requests_total", .help = "..."});
c.inc(); // exported as mylib_requests_total{component="io"}The scope config is live, not copied at creation: changing the prefix or default labels reconfigures every metric already created from the scope, and subsequent samples flow to the newly-derived series.
lib->set_prefix("srv_"); // existing metrics re-register under srv_*
lib->add_const_label("region", "eu");A user of the library can fetch the same scope by name and adjust it:
prom::scope("mylib") returns the existing instance (the config argument is
ignored once a scope exists — reconfigure through the setters instead).
CompositeAdapter forwards every call to a fixed list of child adapters —
useful for teeing metrics to two exporters during a migration, or feeding a real
backend and a test recorder at once:
#include <prom/prom.hpp>
auto composite = std::make_shared<prom::CompositeAdapter>(
std::vector<prom::AdapterPtr>{backend_a, backend_b});
prom::Registry::global()->set_adapter(composite);Null entries in the list are dropped. The composite needs no locking of its own (the list is fixed at construction), assuming each child honours the adapter threading contract.
for (const prom::MetricInfo& m : prom::Registry::global()->metrics()) {
// m.type, m.name, m.help, m.const_labels, m.unit, m.scoped
}
for (const auto& s : prom::scopes()) { /* s->metrics() ... */ }
auto names = prom::scope_names();metrics() returns read-only snapshots — including declared-but-not-yet-used
metrics — with a scope's effective name and labels computed live. The Unit
string views in a MetricInfo reference the live metric's storage, so use a
snapshot only while that metric is alive.
The prometheus-cpp adapter (prom::prometheus_cpp, gated by
-DPROM_WITH_PROMETHEUS_CPP=ON) is what a host installs to actually export:
#include <prom/prom.hpp>
#include <prom/prometheus_cpp/adapter.hpp>
#include <prometheus/registry.h>
#include <prometheus/text_serializer.h>
#include <iostream>
#include <memory>
int main() {
// 1. Install the backend once, at startup.
auto adapter = std::make_shared<prom::prometheus_cpp::PrometheusCppAdapter>();
prom::Registry::global()->set_adapter(adapter);
// 2. Library code records through prom exactly as before.
auto reg = prom::Registry::global();
auto requests = reg->counter({.name = "http_requests_total",
.help = "Total HTTP requests",
.labels = prom::Labels{{"service", "api"}}});
auto latency = reg->histogram({.name = "http_request_seconds", .help = "Latency",
.buckets = {0.05, 0.1, 0.25, 0.5, 1.0}});
requests.inc();
requests.labels(prom::Labels{{"code", "200"}}).inc(3);
latency.observe(0.08);
latency.observe(0.42);
// 3. Expose adapter->registry() through an HTTP scrape endpoint, or render it:
std::cout << prometheus::TextSerializer().Serialize(adapter->registry().Collect());
}which yields standard Prometheus exposition text:
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{service="api",code="200"} 3
http_requests_total{service="api"} 1
# HELP http_request_seconds Latency
# TYPE http_request_seconds histogram
http_request_seconds_count 2
http_request_seconds_sum 0.5
http_request_seconds_bucket{le="0.05"} 0
http_request_seconds_bucket{le="0.1"} 1
...
Type mapping. Counter / Gauge / Histogram / Summary map to their direct
prometheus-cpp equivalents. Untyped maps to a Gauge. Info maps to a <name>
Gauge whose label set carries the payload at value 1. StateSet maps to a
Gauge family with one series per state, each 0 or 1.
Pass an existing registry to the adapter constructor
(PrometheusCppAdapter{my_registry}) when you already have one wired to a
prometheus::Exposer.
prom splits errors cleanly between definition time and recording time.
Definition / registration validates. Each Registry factory checks its
spec:
- The throwing factories (
counter,gauge, ...) raiseprom::Exceptionon a bad spec. - The
noexceptmirrors (try_counter,try_gauge, ...) returnprom::expected<T>(an alias forstd::expected<T, prom::Error>) instead.
// Throwing form:
try {
auto bad = prom::counter({.name = "9bad", .help = "h"}); // invalid name
} catch (const prom::Exception& e) {
std::cerr << e.what() << '\n'; // "invalid metric name: 9bad"
}
// noexcept form — no exceptions, inspect the result:
prom::expected<prom::Gauge> g = prom::try_gauge({.name = "9bad", .help = "h"});
if (!g) {
// g.error().code == prom::ErrorCode::InvalidMetricName
// g.error().message == "9bad"
}What is validated:
- Metric name against
[a-zA-Z_][a-zA-Z0-9_]*(InvalidMetricName). - Label names, which additionally reject the reserved
__prefix (InvalidLabelName). - Histogram buckets must be non-empty, finite, and strictly increasing
(
InvalidBuckets). - Summary quantiles must lie in the open interval
(0, 1)(InvalidQuantiles). - A state set must declare at least one state (
EmptyStateSet).
All mutations are noexcept. Once a metric exists, recording never throws.
Invalid samples are dropped and logged (see below). The no-client path never
throws because a metric always resolves to at least the NullAdapter.
Note: the
EmptyHelperror code exists in the enum, but help text is not currently rejected by the factories — an empty.helpis accepted (inferred from the validation code). Supply meaningful help text anyway; backends and dashboards rely on it.
Negative counter increment. Dropped and logged; the counter stays monotonic.
auto c = prom::counter({.name = "x_total", .help = "h"});
c.inc(-5); // no-op, logs a warning. c.inc(5) is fine.Non-finite sample (NaN / Inf). Dropped and logged on any
inc/set/dec/observe.
Unit-kind mismatch. The first dimensional sample latches the metric's unit (name, kind, symbol). A later dimensional sample of a different kind is dropped and logged — never thrown. Declare a unit in the spec if you need it fixed up front.
Adapter swap orphans old series. set_adapter(...) re-registers each metric
against the new backend on its next use, but series already written to the
previous backend stay there (backends cannot move a registered series). Install
the backend before the bulk of your samples flow.
Labeled children do not migrate. A child created with labels() pins its
binding at creation. If you swap the adapter (or reconfigure the scope)
afterward, the existing child keeps recording to the old binding. Re-resolve
children after the swap.
Standalone metric used before the backend is installed. It binds to the
NullAdapter on first use, then re-binds to the real backend on the next use
after set_adapter(...) — but anything recorded in between went to the
NullAdapter and is lost. Again: install early.
set_unit is best-effort. The prometheus-cpp backend cannot rename an
already-registered family, so late (inferred) units affect prom's own
bookkeeping and the sample-dropping kind reconciliation, not the exported
series name. If you want the unit in the name, declare it in the spec.
Duplicate labels collapse. prom::Labels{{"k","a"},{"k","b"}} keeps only
k="b" (last write wins), and labels are always sorted by name.
MetricInfo lifetime. The Unit string views in an enumeration snapshot
point into the live metric. Don't keep a snapshot past the metric's lifetime.
A compact map of the public surface a typical user reaches for first.
| API | Purpose | Notes |
|---|---|---|
prom::counter/gauge/histogram/summary/untyped/info/stateset(spec) |
Create a metric via the global registry | Throws prom::Exception on a bad spec. |
prom::try_counter(...) (and the rest) |
noexcept mirrors |
Return prom::expected<T>. |
prom::Registry::global() |
Process-wide registry | Shares the global adapter cell. |
prom::Registry::create(adapter) |
An independent registry with its own cell | For tests / embedders. |
prom::Registry::create(adapter, config) |
A registry that decorates its metrics | Live prefix / labels / display, like a scope. |
Registry::set_adapter(ptr) |
Install / swap the backend (or reset with nullptr) |
Existing metrics re-register on next use. |
Registry::metrics() |
Enumerate declared metrics as MetricInfo |
Includes unused ones. |
prom::scope(name[, config]) |
Get-or-create a named per-library scope | Live, reconfigurable prefix/labels/display. |
prom::scopes() / scope_names() / find_scope(name) |
Enumerate / look up scopes | find_scope returns nullptr if absent. |
Metric::inc/dec/set/observe(...) |
Record a sample | noexcept; raw or dimensional value. |
Metric::labels(Labels) |
A same-type labeled child | Pinned binding (see pitfalls). |
prom::Labels |
Sorted, deduped, hashed label set | {{ "k", "v" }, ...}. |
prom::Adapter |
The backend interface | Subclass to write a new backend. |
prom::NullAdapter |
The default no-op backend | Always available, thread-safe. |
prom::CompositeAdapter |
Fan out to several backends | Fixed child list. |
prom::prometheus_cpp::PrometheusCppAdapter |
The prometheus-cpp backend | Opt-in module. |
All examples live in examples/ and build against the NullAdapter (no backend
needed), except the prometheus-cpp one.
| Example | Demonstrates |
|---|---|
examples/null_only.cpp |
The minimal program: metrics with only the NullAdapter. Start here. |
examples/library_metrics.cpp |
The headline use case: a library instrumented with no backend dependency. |
examples/raw_values.cpp |
Recording plain arithmetic values of various types. |
examples/dimval_values.cpp |
Recording dimensional (dimval) values; unit inference. |
examples/labeled.cpp |
Labeled child series from one family. |
examples/all_metric_types.cpp |
Exercises every metric type once. |
adapters/prometheus_cpp/examples/prometheus_backend.cpp |
Installs the real backend and prints scrape text. |
The test suite uses GoogleTest (fetched automatically). The convenience
Makefile wraps the common CMake/CTest invocations:
make test # configure + build + run core + NullAdapter tests
make prometheus # same, with -DPROM_WITH_PROMETHEUS_CPP=ON (real backend + scrape tests)
make examples # build and run every example, asserting exit 0
make sanitize # ASan + UBSan
make release # Release build + tests
make tidy # clang-tidy
make format-check # clang-format --dry-run --Werror
make ci # the full pre-push gate (all of the above)Or drive CMake directly:
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure| Option | Default | Meaning |
|---|---|---|
PROM_BUILD_TESTS |
top-level | Build the GoogleTest suite. |
PROM_BUILD_EXAMPLES |
top-level | Build the examples. |
PROM_BUILD_DOCS |
OFF |
Build Doxygen HTML. |
PROM_ENABLE_CLANG_TIDY |
OFF |
Run clang-tidy during the build. |
PROM_ENABLE_SANITIZERS |
OFF |
ASan + UBSan (Debug). |
PROM_ENABLE_COVERAGE |
OFF |
Clang source-based coverage. |
PROM_WARNINGS_AS_ERRORS |
top-level | -Werror / /WX. |
PROM_INSTALL |
top-level | Generate install/export rules. |
PROM_WITH_PROMETHEUS_CPP |
OFF |
Build the prometheus-cpp backend adapter. |
Do I need to link a library, or is it header-only? The core is header-only —
link the prom::prom interface target (which carries the include paths and its
header-only dependencies). The optional prometheus-cpp adapter
(prom::prometheus_cpp) is a compiled static library.
What happens if I record an invalid sample? Mutations never throw. A negative
counter increment, a NaN/Inf value, or a unit-kind mismatch is dropped and
logged. Only definition (creating a metric with a bad spec) can fail — and you
choose throwing (counter) or expected-returning (try_counter) factories.
Can I use this from multiple threads? Yes. Metric mutation and binding are
safe from multiple threads; adapter access on a cell is mutex-guarded and hands
out a shared_ptr copy so a concurrent swap can't invalidate an in-flight
caller. Backends self-synchronize (NullAdapter is stateless; the
prometheus-cpp adapter guards family creation and relies on prometheus-cpp's
atomic series).
Does a metric own its data or borrow it? A metric owns its series state via a
shared_ptr; copies share it. The transient MetricMeta/MetricInfo views and
Unit string views borrow from that state and are only valid while it lives.
What if no backend is ever installed? Everything works as a logged no-op
through the NullAdapter. Nothing is exported, nothing throws.
How do I see the no-op logging? It goes through logman/spdlog under the
prom, prom.null, and prom.composite channels (registration at debug,
mutations at trace). Configure your logman/spdlog level to surface it.
Which compiler versions work? Not formally documented. The code requires C++23 and is built with GCC and Apple Clang/libc++ per the build files. Treat specific versions as inferred.
Contributions to the library are welcome! If you encounter any issues or have suggestions for improvements, please feel free to submit a pull request or open an issue on the project's repository.
This project is licensed under the MIT License. See the LICENSE file for details.