cpp26-reflection · part 23

reflect_telemetry: compile-time Prometheus metrics from annotated fields

· upd 2026-10-06· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

Status: github.com/wrocpp/reflect_telemetry is a placeholder repository: a README with no code, licence or release. This post is a design sketch.

Correction, 6 October 2026. This post said the library was published as v0.1.0 under the MIT licence, and listed what it ships. The repository holds a README and one commit, with no code, licence, tag or release, so there is no library, and the “What the library ships” list included things that do not exist: otlp::exporter and labeled_counter, as well as histograms. The most substantive stand-in is the embedded Compiler Explorer demo: about 90 lines in one file that walk a struct and print Prometheus text for counters and gauges, with a namespace prefix and HELP and TYPE lines taken from annotations. It has no histograms, labels, OTLP export or fault injection, and nothing registers a metrics object: main builds one and passes it to expose_prometheus, so the “auto-registered on first scrape” comment had no mechanism behind it. The benchmark line (about 15 ns per increment, 5 ns per gauge read, 250 ns per exposition walk over 50 metrics) was never measured and is removed, as is “tens of microseconds” for building a runtime registry, which is now marked as not measured. The claims of zero startup cost and zero metadata heap describe the design; the demo builds each metric name as a std::string at scrape time. The chaos example had two problems. fault_point("db_read", .probability = 0.01) does not compile: GCC 16.2.0 reports expected primary-expression before '.' token, because a designated initializer needs braces, and the line now reads fault_point("db_read", {.probability = 0.01}), which compiles and reads back the value through annotations_of. And the sentence saying that every access to conn goes through a fault injector is removed: reflection can read an annotation, but it cannot change what an access to a member does. The text below is left as published, apart from those corrections, so you can read it against this note.

Prometheus is the observability default. Every production C++ service you run has code like this, once per metric:

auto& http_requests = prometheus::BuildCounter()
    .Name("http_requests_total")
    .Help("Total HTTP requests")
    .Register(*registry)
    .Add({});

// Elsewhere, in the request handler:
http_requests.Increment();

Add .Labels({"method", "GET"}), histograms, gauges. Each is lines of boilerplate. Worse: the metric name is a string literal, and the variable name is a C++ identifier. They drift. Rename http_requests_total to api_requests_total in the registry, forget to rename the variable, and you have a pipeline of dashboards watching an orphan metric.

reflect_telemetry’s answer: annotate the field, let reflection do the rest.

#include <reflect_telemetry/metrics.hpp>

struct [[=metrics::namespace_("http")]] HttpStats {
    [[=metrics::counter, =metrics::help("Total HTTP requests.")]]
        std::atomic<std::uint64_t> requests_total;

    [[=metrics::gauge, =metrics::help("Currently open connections.")]]
        std::atomic<std::int64_t> open_connections;

    [[=metrics::histogram{.buckets = {0.1, 1, 10, 100, 1000}},
      =metrics::help("Request duration in ms.")]]
        metrics::histogram_value request_duration_ms;
};

HttpStats stats{};   // default-constructed; the scrape code passes it to expose_prometheus

// In the handler:
stats.requests_total.fetch_add(1);
stats.open_connections.fetch_add(1);
stats.request_duration_ms.observe(duration.count());

// /metrics endpoint emits:
//   # HELP http_requests_total Total HTTP requests.
//   # TYPE http_requests_total counter
//   http_requests_total 42
//   # HELP http_open_connections Currently open connections.
//   # TYPE http_open_connections gauge
//   http_open_connections 7
//   # HELP http_request_duration_ms Request duration in ms.
//   # TYPE http_request_duration_ms histogram
//   http_request_duration_ms_bucket{le="0.1"} 3 ...

Field name requests_total + type atomic<uint64_t> + annotation counter ⇒ Prometheus metric http_requests_total of type counter. Zero plumbing.

The walk

Walking a metrics struct and emitting Prometheus exposition:

template <typename T>
std::string expose_prometheus(T const& obj) {
    std::string out;
    constexpr auto type_ns = std::meta::annotation_of_type<metrics::namespace_tag>(^^T);
    constexpr std::string_view ns = type_ns ? type_ns->value : "";
    constexpr auto ctx = std::meta::access_context::unchecked();

    template for (constexpr auto m
                  : std::define_static_array(
                      std::meta::nonstatic_data_members_of(^^T, ctx))) {
        // Determine metric type via annotation
        constexpr bool is_counter =
            std::meta::annotation_of_type<metrics::counter_tag>(m).has_value();
        constexpr bool is_gauge =
            std::meta::annotation_of_type<metrics::gauge_tag>(m).has_value();
        constexpr bool is_histo =
            std::meta::annotation_of_type<metrics::histogram_tag>(m).has_value();

        if constexpr (is_counter || is_gauge || is_histo) {
            // Build the metric name: "<namespace>_<field>"
            constexpr auto full_name = [&]() {
                std::string name{ns};
                if (!name.empty()) name += '_';
                name += std::meta::identifier_of(m);
                return std::define_static_string(name);
            }();

            // Emit HELP + TYPE lines from the annotation
            if constexpr (auto h
                          = std::meta::annotation_of_type<metrics::help_tag>(m)) {
                out += "# HELP "; out += full_name; out += ' ';
                out += h->text;   out += '\n';
            }
            out += "# TYPE "; out += full_name; out += ' ';
            out += is_counter ? "counter" : is_gauge ? "gauge" : "histogram";
            out += '\n';

            // Emit the current value (dispatched on metric type)
            emit_value(out, full_name, obj.[:m:]);
        }
    }
    return out;
}

The HELP text, metric type, and even the metric name all derive from compile-time reflection. At runtime, /metrics scraping is a constant-time walk over a couple of atomics.

Why compile-time matters here

Runtime registration (what the Prometheus C++ client library does today) has three costs you may not notice until they bite:

  1. Startup overhead. Building the registry costs time that grows with metric count (not measured here). For serverless workloads with cold-start SLOs, that matters.
  2. Memory overhead. Each metric carries its metadata (name + help + label-set) in heap. Hundreds of metrics is nontrivial.
  3. Divergence. The name lives in a string literal next to the code; reflection ties it to the identifier.

The design has no startup registration step (registration is compile time; exposition is a walk), keeps metric names in .rodata as define_static_string literals, and has zero divergence (rename the field, rename the metric). None of this was measured.

OpenTelemetry OTLP export

For the other big observability backend, OTLP over HTTP/gRPC, the same walk emits protobuf messages instead of text:

#include <reflect_telemetry/otlp.hpp>

otlp::exporter exporter{"https://otel-collector:4318"};
exporter.export_(stats);   // pushes all fields as OTLP metrics

The protobuf shape is derived from the same annotations + field reflection. No duplicate registration; one source of truth.

Dynamic labels

Labels get annotated too:

struct HttpStats {
    [[=metrics::counter,
      =metrics::labels("method", "path", "status")]]
    labeled_counter<std::string, std::string, int> requests_total;
};

stats.requests_total.with("GET", "/api/users", 200).fetch_add(1);

The labeled_counter<Labels...> type receives the label spec via CTAD + reflection annotations; runtime label values are hashed into a lock-free map of sub-counters.

Comparison

Framework Source of truth Startup cost Drift-proof
Prometheus C++ client Runtime registration code O(metrics) No
Rust prometheus Macro + struct fields Startup Partial
Java Micrometer @Timed annotations Runtime reflection Yes
C++26 reflect_telemetry Annotated struct fields Zero Yes (compile-time tied)

Among runtime-reflection-using frameworks this matches Java’s Micrometer ergonomics. Among compile-time approaches it goes further than Rust’s prometheus macros because it can consume arbitrary annotations (chaos-injection, owners, SLIs) with the same reflection walk.

Chaos / fault-injection sibling

A companion annotation vocabulary:

struct Database {
    [[=chaos::fault_point("db_read", {.probability = 0.01})]]
    connection conn;
};

Reflection can read this annotation, for example to list the fault points of a struct. It cannot change what an access to conn does: injecting faults would need a wrapper type around the field or explicit calls at each use.

What exists today

github.com/wrocpp/reflect_telemetry is a placeholder: a README with no code, licence or release. The embedded demo above is the most substantive stand-in. It has the counter and gauge annotations, help and namespace_, and an expose_prometheus(obj) walk that prints the text exposition. It does not have histograms, labels, otlp::exporter, labeled_counter, the chaos annotations, or any measured timing.

Coming up: reflect_tracing and a chaos short

  • Post 24: reflect_tracing: span tracing on Maciek Gajewski’s low-overhead engine.
  • Companion Monday short: “Chaos engineering, annotation-driven”.