The trick is to design your common-layer value type as a strict subset of OTel's AttributeValue alternatives. If every alternative your variant can hold is also a native alternative of opentelemetry::common::AttributeValue, then the OTel conversion becomes identity-construction (a one-line visitor that just returns the value), while Prometheus visits the same variant and stringifies — paying the heap/map cost alone. The common header stays OTel-free, so ODR and the Prometheus build are unaffected.
OTel's AttributeValue includes bool, int64_t, double, std::string_view (among its 16 arms). Those are exactly the spec-blessed types — so make your variant those four.
- Common layer (OTel-free) — common/include/ebay/observability/attributes.hpp
#pragma once
#include <concepts>
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <variant>
namespace ebay::observability {
// Subset of opentelemetry::common::AttributeValue's alternatives.
// Each arm is a NATIVE OTel variant alternative => zero-cost OTel mapping.
class attribute_value {
public:
using storage = std::variant<std::string_view, bool, std::int64_t, double>;
attribute_value(std::string_view v) noexcept : m_value(v) {}
attribute_value(const char* v) noexcept : m_value(std::string_view{v}) {} // kills const char*->bool trap
attribute_value(const std::string& v) noexcept : m_value(std::string_view{v}) {}
attribute_value(bool v) noexcept : m_value(v) {}
template <std::integral I> requires (!std::same_as<I, bool>)
attribute_value(I v) noexcept : m_value(static_cast<std::int64_t>(v)) {} // 200 -> int64, no double ambiguity
template <std::floating_point F>
attribute_value(F v) noexcept : m_value(static_cast<double>(v)) {}
template <typename Visitor>
decltype(auto) visit(Visitor&& vis) const { return std::visit(std::forward<Visitor>(vis), m_value); }
private:
storage m_value;
};
using attribute = std::pair<std::string_view, attribute_value>;
using attributes = std::span<const attribute>; // 16-byte non-owning view, no std::function
template <typename R>
concept attribute_range =
std::ranges::contiguous_range<R> &&
std::same_as<std::ranges::range_value_t<R>, attribute>;
} // namespace ebay::observabilityThe explicit const char* ctor and the constrained integral/float ctors are what dodge the two overload traps (const char*→bool, int→int64/double). Keys are std::string_view — which is nostd::string_view at STL-CXX17, so keys cross to OTel with no conversion at all.
- OTel bridge — the "compatible, pays ~nothing" side (opentelemetry/src/impl/otel_attributes.hpp)
#pragma once
#include <opentelemetry/common/attribute_value.h>
#include <opentelemetry/nostd/span.h>
#include <array>
#include <vector>
#include "ebay/observability/attributes.hpp"
namespace ebay::observability::detail {
// Identity-construction: every arm of our variant IS an OTel alternative.
inline opentelemetry::common::AttributeValue to_otel(const attribute_value& v) noexcept {
return v.visit([](auto&& x) -> opentelemetry::common::AttributeValue { return x; });
}
// Stack buffer of native OTel pairs; heap only for the rare large set.
template <std::size_t Inline = 16>
class otel_attributes {
public:
using otel_pair = std::pair<opentelemetry::nostd::string_view,
opentelemetry::common::AttributeValue>;
explicit otel_attributes(attributes attrs) noexcept : m_size(attrs.size()) {
otel_pair* dst;
if (m_size <= Inline) { dst = m_stack.data(); }
else { m_heap.reserve(m_size); m_heap.resize(m_size); dst = m_heap.data(); }
for (std::size_t i = 0; i < m_size; ++i)
dst[i] = otel_pair{ attrs[i].first, to_otel(attrs[i].second) };
m_ptr = dst;
}
opentelemetry::nostd::span<const otel_pair> span() const noexcept {
return { m_ptr, m_size }; // nostd::span(ptr, count) ctor
}
private:
std::array<otel_pair, Inline> m_stack{};
std::vector<otel_pair> m_heap;
otel_pair* m_ptr{nullptr};
std::size_t m_size{0};
};
} // namespace ebay::observability::detailUsage in otel_counter::do_inc(...):
void otel_counter::do_inc(std::uint64_t value, attributes attrs,
const std::optional<trace_context>& tc) {
detail::otel_attributes buf{attrs}; // stack fill, no heap (≤16)
if (auto ctx = make_otel_context(tc)) m_counter->Add(value, buf.span(), *ctx);
else m_counter->Add(value, buf.span());
}Add(value, span) is the ABI-v2 no-context overload (sync_instruments.h) — the lean path. The to_otel visitor compiles to a jump table of trivial constructions; no string copies, no std::function, no heap.
- Prometheus bridge — the side that pays (prometheus/src/impl/prom_attributes.hpp)
struct prom_stringify {
std::string operator()(std::string_view s) const { return std::string{s}; }
std::string operator()(bool b) const { return b ? "true" : "false"; }
std::string operator()(std::int64_t i) const { return std::to_string(i); }
std::string operator()(double d) const {
std::array<char, 32> b; auto [p, ec] = std::to_chars(b.data(), b.data()+b.size(), d);
return std::string{b.data(), p};
}
};
inline std::map<std::string, std::string> to_prom_labels(attributes attrs) {
std::map<std::string, std::string> labels; // owning + map alloc = Prometheus' inherent cost
for (const auto& [k, v] : attrs)
labels.emplace(std::string{k}, v.visit(prom_stringify{}));
return labels;
}Same attributes span, same visit — but Prometheus stringifies and builds its std::map. The cost the review attributes to prometheus-cpp now lands only in the Prometheus TU, exactly as you intended.
- Wiring into metrics.hpp (collapses to one virtual)
class counter {
public:
void inc(std::uint64_t value = 1) noexcept { dispatch(value, attributes{}, std::nullopt); }
void inc(std::uint64_t value, std::initializer_list<attribute> attrs,
const std::optional<trace_context>& tc = std::nullopt) noexcept {
dispatch(value, attributes{attrs.begin(), attrs.size()}, tc); // {{"k","v"},{"status",200}}
}
template <attribute_range R>
void inc(std::uint64_t value, const R& attrs,
const std::optional<trace_context>& tc = std::nullopt) noexcept {
dispatch(value, attributes{std::data(attrs), std::size(attrs)}, tc);
}
protected:
virtual void do_inc(std::uint64_t, attributes, const std::optional<trace_context>&) = 0;
private:
void dispatch(std::uint64_t v, attributes a, const std::optional<trace_context>& tc) noexcept {
safe_metric_operation("counter::inc",
[&]{ do_inc(v, a, tc); },
[this](const char* op, const char* e){ log_metric_error(op, e); });
}
};cppinc(1, {{"method","GET"},{"status",200}}) still compiles (string + typed int side by side) — that's the back-compat win, free thanks to the variant ctors.
The core principle, restated: compatibility comes from type alignment, not from sharing OTel's header. By choosing your variant's alternatives = OTel's spec-supported alternatives, to_otel is a no-op visitor and OTel gets its zero-heap path; Prometheus consumes the identical span and pays the stringify/map cost in isolation.
One caveat to document: values are non-owning string_views, so a temporary std::string passed inline lives only to the end of the call — which is fine because recording is synchronous, but worth a doc note.
Want me to implement this for real now (core-first: attributes.hpp + metrics.hpp + both backend bridges, build, then fix tests/benchmarks/docs)?