Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions include/OtelDefaults.h
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@ inline OTelResourceConfig& defaultResource() {
return rc;
}

/**
* Populate an OTLP resource attributes array by merging runtime defaultResource()
* values with compile-time fallbacks. Runtime values always win: if a key is
* set via defaultResource().set(), the fallback for that key is suppressed.
*/
inline void buildResourceAttributes(JsonArray& attrs,
const String& fallbackServiceName,
const String& fallbackInstanceId,
const String& fallbackHostName)
{
static const String kServiceName("service.name");
static const String kServiceInstanceId("service.instance.id");
static const String kHostName("host.name");

const auto& res = defaultResource();

if (res.attrs.find(kServiceName) == res.attrs.end())
serializeKeyValue(attrs, kServiceName, fallbackServiceName);
if (res.attrs.find(kServiceInstanceId) == res.attrs.end())
serializeKeyValue(attrs, kServiceInstanceId, fallbackInstanceId);
if (res.attrs.find(kHostName) == res.attrs.end())
serializeKeyValue(attrs, kHostName, fallbackHostName);

for (const auto& p : res.attrs)
serializeKeyValue(attrs, p.first, p.second);
}
Comment thread
proffalken marked this conversation as resolved.

} // namespace OTel

#endif // OTEL_DEFAULTS_H
6 changes: 2 additions & 4 deletions include/OtelLogger.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#include <ArduinoJson.h>
#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(), buildResourceAttributes(), defaults

namespace OTel {

Expand Down Expand Up @@ -134,9 +134,7 @@ class Logger {
// Resource (with attributes to ensure service.name lands)
JsonObject resource = rl["resource"].to<JsonObject>();
JsonArray rattrs = resource["attributes"].to<JsonArray>();
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<JsonArray>().add<JsonObject>();
Expand Down
2 changes: 1 addition & 1 deletion include/OtelMetrics.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#include <ArduinoJson.h>
#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 {

Expand Down
107 changes: 89 additions & 18 deletions include/OtelTracer.h
Original file line number Diff line number Diff line change
Expand Up @@ -473,13 +473,30 @@ static inline String generateSpanId() {



/** Append a string-valued OTLP KeyValue object to a resource attributes array. */
/** @deprecated Use buildResourceAttributes() instead. */
[[deprecated("Use buildResourceAttributes() instead")]]
static inline void addResAttr(JsonArray& arr, const char* key, const String& value) {
JsonObject a = arr.add<JsonObject>();
a["key"] = key;
a["value"].to<JsonObject>()["stringValue"] = value;
}

/** OTLP SpanKind enum values. */
namespace SpanKind {
constexpr int INTERNAL = 1;
constexpr int SERVER = 2;
constexpr int CLIENT = 3;
constexpr int PRODUCER = 4;
constexpr int CONSUMER = 5;
}

/** OTLP StatusCode enum values. */
namespace StatusCode {
constexpr int UNSET = 0;
constexpr int OK = 1;
constexpr int ERROR = 2;
}

/** Instrumentation scope name and version emitted on every trace payload. */
struct TracerConfig {
String scopeName{"otel-embedded"};
Expand Down Expand Up @@ -535,6 +552,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
Expand All @@ -544,23 +564,66 @@ class Span {

Span& operator=(Span&& o) noexcept {
if (this != &o) {
if (!ended_) end(); // finish our current span if still open
name_ = std::move(o.name_);
traceId_ = std::move(o.traceId_);
spanId_ = std::move(o.spanId_);
startNs_ = o.startNs_;
prevTraceId_ = std::move(o.prevTraceId_);
prevSpanId_ = std::move(o.prevSpanId_);
attrs_ = std::move(o.attrs_);
events_ = std::move(o.events_);
ended_ = o.ended_;
o.ended_ = true; // source won't end() again
// Check whether the RHS is the active span BEFORE end() restores the context.
// If it is, end() will clobber the context with the LHS parent's IDs, and
// subsequent spans/logs would link to the wrong parent.
bool rhs_was_active = (currentTraceContext().traceId == o.traceId_ &&
currentTraceContext().spanId == o.spanId_);
if (!ended_) end();
name_ = std::move(o.name_);
traceId_ = std::move(o.traceId_);
spanId_ = std::move(o.spanId_);
startNs_ = o.startNs_;
prevTraceId_ = std::move(o.prevTraceId_);
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_);
Comment thread
proffalken marked this conversation as resolved.
ended_ = o.ended_;
o.ended_ = true;
o.prevTraceId_ = "";
o.prevSpanId_ = "";
// Reinstall the moved-in span as active if the source was active.
if (rhs_was_active && !ended_) {
currentTraceContext().traceId = traceId_;
currentTraceContext().spanId = spanId_;
}
}
return *this;
}

/** Set the OTLP SpanKind. Use the SpanKind:: constants. */
Span& setKind(int kind) {
if (kind >= SpanKind::INTERNAL && kind <= SpanKind::CONSUMER) {
kind_ = kind;
} else {
DBG_PRINT("[otel] WARNING: invalid span kind "); DBG_PRINT(kind);
DBG_PRINT(", keeping current ("); DBG_PRINT(kind_); DBG_PRINTLN(")");
}
return *this;
}

/** Set span status explicitly. Use the StatusCode:: constants. */
Span& setStatus(int code, const String& message = "") {
if (code >= StatusCode::UNSET && code <= StatusCode::ERROR) {
statusCode_ = code;
statusMessage_ = message;
} else {
DBG_PRINT("[otel] WARNING: invalid status code "); DBG_PRINT(code);
DBG_PRINTLN(", defaulting to UNSET");
statusCode_ = StatusCode::UNSET;
}
return *this;
}

/** Shorthand for setStatus(StatusCode::ERROR, message). */
Span& setError(const String& message = "") { return setStatus(StatusCode::ERROR, message); }

/** Shorthand for setStatus(StatusCode::OK). */
Span& setOk() { return setStatus(StatusCode::OK); }

/** @{ Add a typed attribute to the span. Attributes are buffered until @c end(). */
Span& setAttribute(const String& key, const String& v) {
//attrs_.push_back(Attr{key, Type::Str, v, 0, 0.0, false});
Expand Down Expand Up @@ -680,9 +743,7 @@ class Span {

// resourceSpans[0].resource.attributes[...]
JsonArray rattrs = doc["resourceSpans"][0]["resource"]["attributes"].to<JsonArray>();
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<JsonObject>();
Expand All @@ -694,7 +755,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);

Expand Down Expand Up @@ -744,6 +805,14 @@ class Span {
}
}

// Span status — only serialise if explicitly set (UNSET is the default)
if (statusCode_ != StatusCode::UNSET) {
JsonObject status = s["status"].to<JsonObject>();
status["code"] = statusCode_;
if (statusCode_ == StatusCode::ERROR && statusMessage_.length() > 0)
status["message"] = statusMessage_;
}
Comment thread
proffalken marked this conversation as resolved.

// Send
OTelSender::sendJson("/v1/traces", doc);
#endif // OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF
Expand Down Expand Up @@ -798,11 +867,13 @@ class Span {
String prevTraceId_;
String prevSpanId_;

// NEW: buffers
std::vector<Attr> attrs_;
std::vector<Event> events_;

// RAII guard
int kind_ = SpanKind::SERVER;
int statusCode_ = StatusCode::UNSET;
String statusMessage_;

bool ended_ = false;
};

Expand Down
11 changes: 2 additions & 9 deletions src/OtelMetrics.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,10 @@ static void addPointAttributes(JsonArray& attrArray,
}
}

/** Populate the OTLP resource object from defaultResource() or compile-time defaults. */
/** Populate the OTLP resource object, merging runtime and compile-time defaults. */
static void addCommonResource(JsonObject& resource) {
auto &res = OTel::defaultResource();
if (!res.empty()) {
res.addResourceAttributes(resource);
return;
}
JsonArray rattrs = resource["attributes"].to<JsonArray>();
addResAttr(rattrs, "service.name", defaultServiceName());
addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId());
addResAttr(rattrs, "host.name", defaultHostName());
buildResourceAttributes(rattrs, defaultServiceName(), defaultServiceInstanceId(), defaultHostName());
}

/** Write the instrumentation scope name and version into @p scope. */
Expand Down
110 changes: 109 additions & 1 deletion test/test_otlp/test_otlp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,101 @@ void test_span_has_non_empty_trace_id() {
TEST_ASSERT_TRUE(strlen(tid) > 0);
}

void test_span_default_kind_is_server() {
auto span = OTel::Tracer::startSpan("my-op");
span.end();
JsonDocument doc;
deserializeJson(doc, FakeSender::lastJson);
int kind = doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["kind"];
TEST_ASSERT_EQUAL_INT(OTel::SpanKind::SERVER, kind);
}

void test_span_setKind_client() {
auto span = OTel::Tracer::startSpan("my-op");
span.setKind(OTel::SpanKind::CLIENT);
span.end();
JsonDocument doc;
deserializeJson(doc, FakeSender::lastJson);
int kind = doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["kind"];
TEST_ASSERT_EQUAL_INT(OTel::SpanKind::CLIENT, kind);
}

void test_span_status_absent_when_unset() {
auto span = OTel::Tracer::startSpan("my-op");
span.end();
JsonDocument doc;
deserializeJson(doc, FakeSender::lastJson);
TEST_ASSERT_TRUE(
doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["status"].isNull());
}

void test_span_setError_emits_status_code_and_message() {
auto span = OTel::Tracer::startSpan("my-op");
span.setError("something failed");
span.end();
JsonDocument doc;
deserializeJson(doc, FakeSender::lastJson);
JsonObject status =
doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["status"];
TEST_ASSERT_EQUAL_INT(OTel::StatusCode::ERROR, (int)status["code"]);
TEST_ASSERT_EQUAL_STRING("something failed", (const char*)status["message"]);
}

void test_span_setOk_emits_status_without_message() {
auto span = OTel::Tracer::startSpan("my-op");
span.setOk();
span.end();
JsonDocument doc;
deserializeJson(doc, FakeSender::lastJson);
JsonObject status =
doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["status"];
TEST_ASSERT_EQUAL_INT(OTel::StatusCode::OK, (int)status["code"]);
TEST_ASSERT_TRUE(status["message"].isNull());
}

// ── Resource attribute merging ────────────────────────────────────────────────

void test_resource_runtime_value_appears_in_span() {
// setUp sets service.name = "test-service" via defaultResource()
auto span = OTel::Tracer::startSpan("my-op");
span.end();
JsonDocument doc;
deserializeJson(doc, FakeSender::lastJson);
JsonArray attrs = doc["resourceSpans"][0]["resource"]["attributes"];
bool found = false;
for (JsonObject a : attrs) {
if (strcmp(a["key"], "service.name") == 0) {
found = (strcmp(a["value"]["stringValue"], "test-service") == 0);
break;
}
}
TEST_ASSERT_TRUE(found);
}

void test_resource_partial_override_keeps_fallback_keys() {
// Clear all runtime attrs, set only service.name — the other keys should
// fall back to compile-time defaults (service.instance.id and host.name).
OTel::defaultResource().clear();
OTel::defaultResource().set("service.name", "partial-override");

auto span = OTel::Tracer::startSpan("my-op");
span.end();
JsonDocument doc;
deserializeJson(doc, FakeSender::lastJson);
JsonArray attrs = doc["resourceSpans"][0]["resource"]["attributes"];

bool hasCustomName = false;
bool hasInstanceId = false;
for (JsonObject a : attrs) {
if (strcmp(a["key"], "service.name") == 0)
hasCustomName = (strcmp(a["value"]["stringValue"], "partial-override") == 0);
if (strcmp(a["key"], "service.instance.id") == 0)
hasInstanceId = true;
}
TEST_ASSERT_TRUE(hasCustomName);
TEST_ASSERT_TRUE(hasInstanceId);
}

// ── Main ──────────────────────────────────────────────────────────────────────

int main() {
Expand All @@ -169,10 +264,23 @@ int main() {
RUN_TEST(test_log_body_string_value);
RUN_TEST(test_log_error_severity_text);

// Traces
// Traces — basic
RUN_TEST(test_span_sends_to_traces_endpoint);
RUN_TEST(test_span_name_in_payload);
RUN_TEST(test_span_has_non_empty_trace_id);

// Traces — span kind
RUN_TEST(test_span_default_kind_is_server);
RUN_TEST(test_span_setKind_client);

// Traces — span status
RUN_TEST(test_span_status_absent_when_unset);
RUN_TEST(test_span_setError_emits_status_code_and_message);
RUN_TEST(test_span_setOk_emits_status_without_message);

// Resource attribute merging
RUN_TEST(test_resource_runtime_value_appears_in_span);
RUN_TEST(test_resource_partial_override_keeps_fallback_keys);

return UNITY_END();
}