|
prom 0.1.0
Client-independent C++23 Prometheus/OpenMetrics metric abstraction
|
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.
A 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.
prom::counter(...) API and the freedom to wire (or not wire) a backend later.prom records samples; serving /metrics is 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.
Why it works:
prom::counter(...) / prom::gauge(...) are free functions that delegate to the process-wide Registry::global(). You never have to create or pass a registry for the common case.NullAdapter.inc, set, dec, ...) is noexcept. 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:
comms::DisplayInfo).prom's own dependencies are each declared with FIND_PACKAGE_ARGS, so an installed copy is preferred over fetching when present.
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):
This pulls in prometheus-cpp via FetchContent and exposes the prom::prometheus_cpp target.
cxx_std_23; CMAKE_CXX_EXTENSIONS OFF).-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.
A 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.
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):
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:
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):
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:
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:
This is what prom is for. The library declares metrics through the free helpers and never mentions a backend:
If the application never installs a backend, mylib still runs — the metrics resolve to the NullAdapter. If it does, the same code starts exporting.
What 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:
A 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:
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(...):
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.
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.
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:
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.
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:
which yields standard Prometheus exposition text:
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:
counter, gauge, ...) raise prom::Exception on a bad spec.noexcept mirrors (try_counter, try_gauge, ...) return prom::expected<T> (an alias for std::expected<T, prom::Error>) instead.What is validated:
[a-zA-Z_][a-zA-Z0-9_]* (InvalidMetricName).__ prefix (InvalidLabelName).InvalidBuckets).(0, 1) (InvalidQuantiles).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.
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:
Or drive CMake directly:
| 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](LICENSE) file for details.