From cc831342d07322e674d6eacb18cd1100e6e2d54c Mon Sep 17 00:00:00 2001 From: Matthew Macdonald-Wallace Date: Mon, 9 Feb 2026 18:15:01 +0000 Subject: [PATCH 1/6] Add runtime resource attributes, span status/kind, and fix ODR singletons Three related improvements: 1. Fix singleton ODR violation across translation units All singleton accessor functions (defaultResource, currentTraceContext, tracerConfig, metricsScopeConfig, defaultMetricLabels, logScopeConfig, defaultLabels) were declared `static inline`, giving each .cpp file its own separate copy of the static local variable. This meant values set in one translation unit (e.g. resource attributes) were invisible to signal builders compiled in other translation units. Changed from `static inline` to `inline` so C++ guarantees a single shared instance across all translation units. 2. Runtime resource attribute support Added buildResourceAttributes() helper that merges runtime defaultResource() values with compile-time fallbacks. All three signal types (traces, metrics, logs) now use this instead of hardcoded addResAttr() calls. This allows callers to set service.name, service.namespace, service.instance.id etc. at runtime via defaultResource().set() and have them appear on all telemetry. 3. Span status and kind API Added setStatus(code, message), setError(message), setOk() for OTLP StatusCode (UNSET=0, OK=1, ERROR=2) and setKind(kind) for SpanKind (INTERNAL=1, SERVER=2, CLIENT=3, PRODUCER=4, CONSUMER=5). Previously kind was hardcoded to SERVER and status was always UNSET, which prevented proper error detection and span classification in backends. Also fixed the move constructor/assignment to transfer the new fields. Co-Authored-By: Claude Opus 4.6 --- include/OtelDefaults.h | 31 +++++++++++++++++++++++- include/OtelLogger.h | 8 +++--- include/OtelMetrics.h | 4 +-- include/OtelTracer.h | 55 ++++++++++++++++++++++++++++++++++++------ src/OtelMetrics.cpp | 4 +-- 5 files changed, 83 insertions(+), 19 deletions(-) diff --git a/include/OtelDefaults.h b/include/OtelDefaults.h index 7222057..9730d13 100644 --- a/include/OtelDefaults.h +++ b/include/OtelDefaults.h @@ -138,12 +138,41 @@ struct OTelResourceConfig { // ------------------------------------------------------------------------------------------------- /** Default resource for general use (metrics/logs/etc.) */ -static inline OTelResourceConfig& defaultResource() { +inline OTelResourceConfig& defaultResource() { static OTelResourceConfig rc; return rc; } +/** + * Build resource attributes into an OTLP JSON attributes array. + * Merges runtime defaultResource() values with compile-time fallbacks. + * Runtime values always win over fallbacks. + */ +static inline void buildResourceAttributes(JsonArray& attrs, + const String& fallbackServiceName, + const String& fallbackInstanceId, + const String& fallbackHostName) +{ + const auto& res = defaultResource(); + + // Add compile-time fallbacks only for keys not set at runtime + if (res.attrs.find("service.name") == res.attrs.end()) { + serializeKeyValue(attrs, "service.name", fallbackServiceName); + } + if (res.attrs.find("service.instance.id") == res.attrs.end()) { + serializeKeyValue(attrs, "service.instance.id", fallbackInstanceId); + } + if (res.attrs.find("host.name") == res.attrs.end()) { + serializeKeyValue(attrs, "host.name", fallbackHostName); + } + + // Add all runtime resource attributes (overrides included) + for (const auto& p : res.attrs) { + serializeKeyValue(attrs, p.first, p.second); + } +} + } // namespace OTel #endif // OTEL_DEFAULTS_H diff --git a/include/OtelLogger.h b/include/OtelLogger.h index 78aa8de..425b884 100644 --- a/include/OtelLogger.h +++ b/include/OtelLogger.h @@ -28,13 +28,13 @@ struct LogScopeConfig { String scopeName{"otel-embedded-cpp"}; String scopeVersion{""}; // optional }; -static inline LogScopeConfig& logScopeConfig() { +inline LogScopeConfig& logScopeConfig() { static LogScopeConfig cfg; return cfg; } // ---- Default labels (merged into each log record's attributes) -------------- -static inline std::map& defaultLabels() { +inline std::map& defaultLabels() { static std::map labels; return labels; } @@ -91,9 +91,7 @@ class Logger { // Resource (with attributes to ensure service.name lands) JsonObject resource = rl["resource"].to(); JsonArray rattrs = resource["attributes"].to(); - addResAttr(rattrs, "service.name", defaultServiceName()); - addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId()); - addResAttr(rattrs, "host.name", defaultHostName()); + buildResourceAttributes(rattrs, defaultServiceName(), defaultServiceInstanceId(), defaultHostName()); // Scope JsonObject sl = rl["scopeLogs"].to().add(); diff --git a/include/OtelMetrics.h b/include/OtelMetrics.h index 10b3e62..85f6933 100644 --- a/include/OtelMetrics.h +++ b/include/OtelMetrics.h @@ -18,13 +18,13 @@ struct MetricsScopeConfig { String scopeVersion{"0.1.0"}; }; -static inline MetricsScopeConfig& metricsScopeConfig() { +inline MetricsScopeConfig& metricsScopeConfig() { static MetricsScopeConfig cfg; return cfg; } // ---- Default metric labels (merged into each datapoint's attributes) -------- -static inline std::map& defaultMetricLabels() { +inline std::map& defaultMetricLabels() { static std::map labels; return labels; } diff --git a/include/OtelTracer.h b/include/OtelTracer.h index 6ddca27..6d6fef0 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -30,7 +30,7 @@ struct TraceContext { bool valid() const { return traceId.length() == 32 && spanId.length() == 16; } }; -static inline TraceContext& currentTraceContext() { +inline TraceContext& currentTraceContext() { static TraceContext ctx; return ctx; } @@ -418,7 +418,7 @@ struct TracerConfig { String scopeVersion{"0.1.0"}; }; -static inline TracerConfig& tracerConfig() { +inline TracerConfig& tracerConfig() { static TracerConfig cfg; return cfg; } @@ -460,6 +460,9 @@ class Span { prevSpanId_(std::move(o.prevSpanId_)), attrs_(std::move(o.attrs_)), events_(std::move(o.events_)), + kind_(o.kind_), + statusCode_(o.statusCode_), + statusMessage_(std::move(o.statusMessage_)), ended_(o.ended_) { o.ended_ = true; // source dtor becomes a no-op @@ -478,6 +481,9 @@ class Span { prevSpanId_ = std::move(o.prevSpanId_); attrs_ = std::move(o.attrs_); events_ = std::move(o.events_); + kind_ = o.kind_; + statusCode_ = o.statusCode_; + statusMessage_ = std::move(o.statusMessage_); ended_ = o.ended_; o.ended_ = true; // source won't end() again o.prevTraceId_ = ""; @@ -486,7 +492,28 @@ class Span { return *this; } - // ---------- NEW: span attributes API --------------------------------------- + // ---------- Span status (OTLP StatusCode) ----------------------------------- + // UNSET=0, OK=1, ERROR=2 + Span& setStatus(int code, const String& message = "") { + statusCode_ = code; + statusMessage_ = message; + return *this; + } + Span& setError(const String& message = "") { + return setStatus(2, message); + } + Span& setOk() { + return setStatus(1); + } + + // ---------- Span kind (OTLP SpanKind) -------------------------------------- + // INTERNAL=1, SERVER=2, CLIENT=3, PRODUCER=4, CONSUMER=5 + Span& setKind(int kind) { + kind_ = kind; + return *this; + } + + // ---------- Span attributes API -------------------------------------------- // These buffer attributes until end() and are rendered into OTLP JSON. Span& setAttribute(const String& key, const String& v) { //attrs_.push_back(Attr{key, Type::Str, v, 0, 0.0, false}); @@ -558,9 +585,7 @@ class Span { // resourceSpans[0].resource.attributes[...] JsonArray rattrs = doc["resourceSpans"][0]["resource"]["attributes"].to(); - addResAttr(rattrs, "service.name", defaultServiceName()); - addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId()); - addResAttr(rattrs, "host.name", defaultHostName()); + buildResourceAttributes(rattrs, defaultServiceName(), defaultServiceInstanceId(), defaultHostName()); // instrumentation scope JsonObject scope = doc["resourceSpans"][0]["scopeSpans"][0]["scope"].to(); @@ -572,7 +597,7 @@ class Span { s["traceId"] = traceId_; s["spanId"] = spanId_; s["name"] = name_; - s["kind"] = 2; // SERVER by default; adjust if you have a setter + s["kind"] = kind_; s["startTimeUnixNano"] = u64ToStr(startNs_); s["endTimeUnixNano"] = u64ToStr(endNs); @@ -622,6 +647,15 @@ class Span { } } + // Span status (only serialise if explicitly set) + if (statusCode_ != 0) { + JsonObject status = s["status"].to(); + status["code"] = statusCode_; + if (statusMessage_.length() > 0) { + status["message"] = statusMessage_; + } + } + // Send OTelSender::sendJson("/v1/traces", doc); @@ -675,10 +709,15 @@ class Span { String prevTraceId_; String prevSpanId_; - // NEW: buffers + // Buffers std::vector attrs_; std::vector events_; + // Span kind and status + int kind_ = 2; // SERVER by default + int statusCode_ = 0; // UNSET=0, OK=1, ERROR=2 + String statusMessage_; + // RAII guard bool ended_ = false; }; diff --git a/src/OtelMetrics.cpp b/src/OtelMetrics.cpp index 36fcbc1..c863822 100644 --- a/src/OtelMetrics.cpp +++ b/src/OtelMetrics.cpp @@ -21,9 +21,7 @@ static void addPointAttributes(JsonArray& attrArray, static void addCommonResource(JsonObject& resource) { JsonArray rattrs = resource["attributes"].to(); - addResAttr(rattrs, "service.name", defaultServiceName()); - addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId()); - addResAttr(rattrs, "host.name", defaultHostName()); + buildResourceAttributes(rattrs, defaultServiceName(), defaultServiceInstanceId(), defaultHostName()); } static void addCommonScope(JsonObject& scope) { From 1a7065eb2c0a93e16e8e62a59f6152923215afa8 Mon Sep 17 00:00:00 2001 From: Matthew Macdonald-Wallace Date: Mon, 9 Feb 2026 20:34:07 +0000 Subject: [PATCH 2/6] Update include/OtelTracer.h Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- include/OtelTracer.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/OtelTracer.h b/include/OtelTracer.h index 6d6fef0..942cfe2 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -651,7 +651,7 @@ class Span { if (statusCode_ != 0) { JsonObject status = s["status"].to(); status["code"] = statusCode_; - if (statusMessage_.length() > 0) { + if (statusCode_ == 2 && statusMessage_.length() > 0) { status["message"] = statusMessage_; } } From cf47735c804fcfa8d465149ccdbc87b1be0d2390 Mon Sep 17 00:00:00 2001 From: Matthew Macdonald-Wallace Date: Mon, 9 Feb 2026 20:35:30 +0000 Subject: [PATCH 3/6] Update include/OtelTracer.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- include/OtelTracer.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/include/OtelTracer.h b/include/OtelTracer.h index 942cfe2..1515002 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -509,7 +509,11 @@ class Span { // ---------- Span kind (OTLP SpanKind) -------------------------------------- // INTERNAL=1, SERVER=2, CLIENT=3, PRODUCER=4, CONSUMER=5 Span& setKind(int kind) { - kind_ = kind; + // Validate input to avoid emitting invalid OTLP SpanKind values. + // Only update kind_ if the provided value is within the allowed range. + if (kind >= 1 && kind <= 5) { + kind_ = kind; + } return *this; } From 0c75f5b7510409dd66e77c6becacd47c3b0b25d7 Mon Sep 17 00:00:00 2001 From: Matthew Macdonald-Wallace Date: Mon, 9 Feb 2026 20:35:57 +0000 Subject: [PATCH 4/6] Update include/OtelTracer.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- include/OtelTracer.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/include/OtelTracer.h b/include/OtelTracer.h index 1515002..f595ea0 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -495,7 +495,14 @@ class Span { // ---------- Span status (OTLP StatusCode) ----------------------------------- // UNSET=0, OK=1, ERROR=2 Span& setStatus(int code, const String& message = "") { - statusCode_ = code; + // Clamp to valid OTLP StatusCode range to ensure spec-compliant payloads. + if (code < 0) { + statusCode_ = 0; // UNSET + } else if (code > 2) { + statusCode_ = 2; // ERROR + } else { + statusCode_ = code; + } statusMessage_ = message; return *this; } From 154ba81236b423592a1d8749aceb353d4440f482 Mon Sep 17 00:00:00 2001 From: Matthew Macdonald-Wallace Date: Tue, 10 Feb 2026 10:04:25 +0000 Subject: [PATCH 5/6] Address review feedback from CodeRabbit and Copilot - Add SpanKind and StatusCode named constants to replace magic ints - Validate setStatus/setKind inputs, clamp invalid codes to UNSET - Only serialize status message for ERROR per OTLP spec - Change buildResourceAttributes from static inline to inline - Use pre-constructed static String keys to avoid heap allocations - Remove dead addResAttr helpers (namespace-level and Span private) - Update include comments that referenced removed helpers Co-Authored-By: Claude Opus 4.6 --- include/OtelDefaults.h | 19 +++++++++------ include/OtelLogger.h | 2 +- include/OtelMetrics.h | 2 +- include/OtelTracer.h | 53 +++++++++++++++++++++--------------------- 4 files changed, 40 insertions(+), 36 deletions(-) diff --git a/include/OtelDefaults.h b/include/OtelDefaults.h index 9730d13..c417a4d 100644 --- a/include/OtelDefaults.h +++ b/include/OtelDefaults.h @@ -149,22 +149,27 @@ inline OTelResourceConfig& defaultResource() { * Merges runtime defaultResource() values with compile-time fallbacks. * Runtime values always win over fallbacks. */ -static inline void buildResourceAttributes(JsonArray& attrs, +inline void buildResourceAttributes(JsonArray& attrs, const String& fallbackServiceName, const String& fallbackInstanceId, const String& fallbackHostName) { + // Pre-constructed keys to avoid per-call temporary String heap allocations + static const String kServiceName("service.name"); + static const String kServiceInstanceId("service.instance.id"); + static const String kHostName("host.name"); + const auto& res = defaultResource(); // Add compile-time fallbacks only for keys not set at runtime - if (res.attrs.find("service.name") == res.attrs.end()) { - serializeKeyValue(attrs, "service.name", fallbackServiceName); + if (res.attrs.find(kServiceName) == res.attrs.end()) { + serializeKeyValue(attrs, kServiceName, fallbackServiceName); } - if (res.attrs.find("service.instance.id") == res.attrs.end()) { - serializeKeyValue(attrs, "service.instance.id", fallbackInstanceId); + if (res.attrs.find(kServiceInstanceId) == res.attrs.end()) { + serializeKeyValue(attrs, kServiceInstanceId, fallbackInstanceId); } - if (res.attrs.find("host.name") == res.attrs.end()) { - serializeKeyValue(attrs, "host.name", fallbackHostName); + if (res.attrs.find(kHostName) == res.attrs.end()) { + serializeKeyValue(attrs, kHostName, fallbackHostName); } // Add all runtime resource attributes (overrides included) diff --git a/include/OtelLogger.h b/include/OtelLogger.h index 425b884..5d008a6 100644 --- a/include/OtelLogger.h +++ b/include/OtelLogger.h @@ -8,7 +8,7 @@ #include #include "OtelDefaults.h" // expects: nowUnixNano() #include "OtelSender.h" // expects: OTelSender::sendJson(path, doc) -#include "OtelTracer.h" // provides: currentTraceContext(), u64ToStr(), defaults & addResAttr helpers +#include "OtelTracer.h" // provides: currentTraceContext(), u64ToStr(), defaults namespace OTel { diff --git a/include/OtelMetrics.h b/include/OtelMetrics.h index 85f6933..959712d 100644 --- a/include/OtelMetrics.h +++ b/include/OtelMetrics.h @@ -8,7 +8,7 @@ #include #include "OtelDefaults.h" // expects: nowUnixNano() #include "OtelSender.h" // expects: OTelSender::sendJson(path, doc) -#include "OtelTracer.h" // reuses: u64ToStr(), defaultServiceName(), defaultServiceInstanceId(), defaultHostName(), addResAttr() +#include "OtelTracer.h" // reuses: u64ToStr(), defaultServiceName(), defaultServiceInstanceId(), defaultHostName() namespace OTel { diff --git a/include/OtelTracer.h b/include/OtelTracer.h index f595ea0..59a6399 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -405,11 +405,20 @@ static inline String generateSpanId() { -// Add one string attribute to a resource attributes array -static inline void addResAttr(JsonArray& arr, const char* key, const String& value) { - JsonObject a = arr.add(); - a["key"] = key; - a["value"].to()["stringValue"] = value; +// ---- OTLP SpanKind constants ------------------------------------------------ +namespace SpanKind { + constexpr int INTERNAL = 1; + constexpr int SERVER = 2; + constexpr int CLIENT = 3; + constexpr int PRODUCER = 4; + constexpr int CONSUMER = 5; +} + +// ---- OTLP StatusCode constants ---------------------------------------------- +namespace StatusCode { + constexpr int UNSET = 0; + constexpr int OK = 1; + constexpr int ERROR = 2; } // ---- Tracer configuration --------------------------------------------------- @@ -493,33 +502,29 @@ class Span { } // ---------- Span status (OTLP StatusCode) ----------------------------------- - // UNSET=0, OK=1, ERROR=2 Span& setStatus(int code, const String& message = "") { - // Clamp to valid OTLP StatusCode range to ensure spec-compliant payloads. - if (code < 0) { - statusCode_ = 0; // UNSET - } else if (code > 2) { - statusCode_ = 2; // ERROR - } else { + if (code >= StatusCode::UNSET && code <= StatusCode::ERROR) { statusCode_ = code; + } else { + statusCode_ = StatusCode::UNSET; + Serial.printf("[otel] WARNING: invalid status code %d, defaulting to UNSET\n", code); } statusMessage_ = message; return *this; } Span& setError(const String& message = "") { - return setStatus(2, message); + return setStatus(StatusCode::ERROR, message); } Span& setOk() { - return setStatus(1); + return setStatus(StatusCode::OK); } // ---------- Span kind (OTLP SpanKind) -------------------------------------- - // INTERNAL=1, SERVER=2, CLIENT=3, PRODUCER=4, CONSUMER=5 Span& setKind(int kind) { - // Validate input to avoid emitting invalid OTLP SpanKind values. - // Only update kind_ if the provided value is within the allowed range. - if (kind >= 1 && kind <= 5) { + if (kind >= SpanKind::INTERNAL && kind <= SpanKind::CONSUMER) { kind_ = kind; + } else { + Serial.printf("[otel] WARNING: invalid span kind %d, keeping SERVER\n", kind); } return *this; } @@ -659,10 +664,11 @@ class Span { } // Span status (only serialise if explicitly set) - if (statusCode_ != 0) { + if (statusCode_ != StatusCode::UNSET) { JsonObject status = s["status"].to(); status["code"] = statusCode_; - if (statusCode_ == 2 && statusMessage_.length() > 0) { + // Per OTLP spec, message is only meaningful for ERROR status + if (statusCode_ == StatusCode::ERROR && statusMessage_.length() > 0) { status["message"] = statusMessage_; } } @@ -680,13 +686,6 @@ class Span { const String& spanId() const { return spanId_; } private: - // Utility to add a resource attribute - static inline void addResAttr(JsonArray& arr, const char* key, const String& val) { - JsonObject a = arr.add(); - a["key"] = key; - a["value"]["stringValue"] = val; - } - static inline String u64ToStr(uint64_t v) { // Avoid ambiguous Arduino String(uint64_t) by formatting manually char buf[32]; From a639e5fd82cba4eabedbd60d1e69e0d662e09d93 Mon Sep 17 00:00:00 2001 From: Matthew Macdonald-Wallace Date: Tue, 10 Feb 2026 10:22:57 +0000 Subject: [PATCH 6/6] Keep addResAttr() as deprecated wrapper for backwards compatibility Existing users may call addResAttr() directly. Rather than removing it outright, mark it [[deprecated]] pointing to buildResourceAttributes(). Co-Authored-By: Claude Opus 4.6 --- include/OtelTracer.h | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/include/OtelTracer.h b/include/OtelTracer.h index 59a6399..579905d 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -405,6 +405,15 @@ static inline String generateSpanId() { +// ---- Deprecated: use buildResourceAttributes() instead ---------------------- +// Kept for backwards compatibility with existing user code. +[[deprecated("Use buildResourceAttributes() instead")]] +static inline void addResAttr(JsonArray& arr, const char* key, const String& value) { + JsonObject a = arr.add(); + a["key"] = key; + a["value"].to()["stringValue"] = value; +} + // ---- OTLP SpanKind constants ------------------------------------------------ namespace SpanKind { constexpr int INTERNAL = 1;