diff --git a/BUILD.bazel b/BUILD.bazel index df17ac563..0b467a696 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -55,6 +55,7 @@ cc_library( "src/datadog/span.cpp", "src/datadog/span_data.cpp", "src/datadog/span_data.h", + "src/datadog/span_link.cpp", "src/datadog/span_matcher.cpp", "src/datadog/span_sampler.cpp", "src/datadog/span_sampler.h", @@ -112,6 +113,7 @@ cc_library( "include/datadog/environment.h", "include/datadog/error.h", "include/datadog/event_scheduler.h", + "include/datadog/extracted_context.h", "include/datadog/expected.h", "include/datadog/http_client.h", "include/datadog/http_endpoint_calculation_mode.h", @@ -132,6 +134,7 @@ cc_library( "include/datadog/span.h", "include/datadog/span_config.h", "include/datadog/span_defaults.h", + "include/datadog/span_link.h", "include/datadog/span_matcher.h", "include/datadog/span_sampler_config.h", "include/datadog/string_view.h", diff --git a/CMakeLists.txt b/CMakeLists.txt index 8cc154f17..8a1bd78a1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -160,6 +160,7 @@ target_sources(dd-trace-cpp-objects include/datadog/span.h include/datadog/span_config.h include/datadog/span_defaults.h + include/datadog/span_link.h include/datadog/span_matcher.h include/datadog/span_sampler_config.h include/datadog/string_view.h @@ -203,6 +204,7 @@ target_sources(dd-trace-cpp-objects src/datadog/runtime_id.cpp src/datadog/span.cpp src/datadog/span_data.cpp + src/datadog/span_link.cpp src/datadog/span_matcher.cpp src/datadog/span_sampler_config.cpp src/datadog/span_sampler.cpp diff --git a/include/datadog/extracted_context.h b/include/datadog/extracted_context.h new file mode 100644 index 000000000..4dff49846 --- /dev/null +++ b/include/datadog/extracted_context.h @@ -0,0 +1,30 @@ +#pragma once + +// Tracing context extracted from incoming distributed headers. Unlike `Span`, +// this type holds only the propagation fields of the upstream context and does +// not represent a locally-owned span. + +#include +#include + +#include "optional.h" +#include "trace_id.h" + +namespace datadog { +namespace tracing { + +struct ExtractedContext { + TraceID trace_id; + // The upstream span's ID (e.g. x-datadog-parent-id or traceparent + // parent-id field). + std::uint64_t span_id = 0; + // W3C tracestate, if present in the incoming headers. + Optional tracestate; + // W3C trace flags byte, if present in the incoming traceparent header. + Optional flags; + // Sampling priority extracted from the incoming headers. + Optional sampling_priority; +}; + +} // namespace tracing +} // namespace datadog diff --git a/include/datadog/span.h b/include/datadog/span.h index 0018230f6..9f2126b43 100644 --- a/include/datadog/span.h +++ b/include/datadog/span.h @@ -47,6 +47,7 @@ #include "clock.h" #include "optional.h" +#include "span_link.h" #include "string_view.h" #include "trace_id.h" #include "trace_source.h" @@ -54,6 +55,7 @@ namespace datadog { namespace tracing { +struct ExtractedContext; struct InjectionOptions; class DictReader; class DictWriter; @@ -166,6 +168,15 @@ class Span { // Specifies the product (AppSec, DBM) that created this span. void set_source(Source); + // Add a span link to this span, filled from the specified live span. + // The linked span's trace ID, span ID, tracestate, and trace flags are + // populated automatically. `attrs` is optional user-supplied attributes. + void add_link(const Span& linked, const SpanLinkAttributes& attrs = {}); + // Add a span link to this span, filled from the specified extracted + // distributed-tracing context. + void add_link(const ExtractedContext& ctx, + const SpanLinkAttributes& attrs = {}); + // Write information about this span and its trace into the specified `writer` // using all of the configured injection propagation styles. void inject(DictWriter& writer) const; diff --git a/include/datadog/span_link.h b/include/datadog/span_link.h new file mode 100644 index 000000000..198be47f8 --- /dev/null +++ b/include/datadog/span_link.h @@ -0,0 +1,41 @@ +#pragma once + +// This component defines `SpanLink`, a causal association between the span that +// owns the link and another span (possibly in another trace). Span links carry +// the linked span's identifiers plus optional W3C tracestate, trace flags, and +// arbitrary string attributes. They are serialized into the owning span under +// the `span_links` key in a format shared by all Datadog tracers. + +#include +#include +#include + +#include "expected.h" +#include "optional.h" +#include "trace_id.h" + +namespace datadog { +namespace tracing { + +// Convenience alias: the map type used for span link user attributes. +using SpanLinkAttributes = std::unordered_map; + +struct SpanLink { + // 128-bit trace ID of the linked span. + TraceID trace_id; + // The linked span's ID. + std::uint64_t span_id = 0; + // W3C `tracestate` header value from the linked context, if any. + Optional tracestate; + // Additional string attributes associated with the link. + std::unordered_map attributes; + // W3C trace flags from the linked context, if any. + Optional flags; +}; + +// Append to the specified `destination` the MessagePack representation of the +// specified `link`. +Expected msgpack_encode(std::string& destination, const SpanLink& link); + +} // namespace tracing +} // namespace datadog diff --git a/src/datadog/span.cpp b/src/datadog/span.cpp index 7d076996b..ec2eb8a90 100644 --- a/src/datadog/span.cpp +++ b/src/datadog/span.cpp @@ -1,7 +1,9 @@ #include +#include #include #include #include +#include #include #include @@ -164,6 +166,53 @@ void Span::set_source(Source source) { to_tag(source)); } +void Span::add_link(const Span& linked, const SpanLinkAttributes& attrs) { + SpanLink link; + link.trace_id = linked.trace_id(); + link.span_id = linked.id(); + link.attributes = attrs; + + // Capture injected headers (tracestate, traceparent flags) into a map. + struct : DictWriter { + std::unordered_map map; + void set(StringView k, StringView v) override { + map[std::string(k)] = std::string(v); + } + } w; + linked.inject(w); + if (auto it = w.map.find("tracestate"); it != w.map.end()) { + link.tracestate = it->second; + } + if (auto it = w.map.find("traceparent"); it != w.map.end()) { + const auto& tp = it->second; + const auto pos = tp.rfind('-'); + if (pos != std::string::npos && pos + 1 < tp.size()) { + try { + link.flags = static_cast( + std::stoul(tp.substr(pos + 1), nullptr, 16)); + } catch (...) { + } + } + } + + data_->span_links.push_back(std::move(link)); +} + +void Span::add_link(const ExtractedContext& ctx, + const SpanLinkAttributes& attrs) { + SpanLink link; + link.trace_id = ctx.trace_id; + link.span_id = ctx.span_id; + link.tracestate = ctx.tracestate; + link.attributes = attrs; + if (ctx.flags.has_value()) { + link.flags = ctx.flags; + } else if (ctx.sampling_priority.has_value()) { + link.flags = *ctx.sampling_priority > 0 ? 1u : 0u; + } + data_->span_links.push_back(std::move(link)); +} + TraceSegment& Span::trace_segment() { return *trace_segment_; } const TraceSegment& Span::trace_segment() const { return *trace_segment_; } diff --git a/src/datadog/span_data.cpp b/src/datadog/span_data.cpp index 7ed4ebb21..c54fb9058 100644 --- a/src/datadog/span_data.cpp +++ b/src/datadog/span_data.cpp @@ -75,7 +75,13 @@ void SpanData::apply_config(const SpanDefaults& defaults, Expected msgpack_encode(std::string& destination, const SpanData& span) { // clang-format off - msgpack::pack_map( + const bool has_links = !span.span_links.empty(); + + // 12 always-present fields, plus span_links when there are any. + auto result = msgpack::pack_map(destination, has_links ? 13u : 12u); + if (!result) return result; + + result = msgpack::pack_map_suffix( destination, "service", [&](auto& destination) { return msgpack::pack_string(destination, span.service); @@ -131,6 +137,18 @@ Expected msgpack_encode(std::string& destination, const SpanData& span) { }, "type", [&](auto& destination) { return msgpack::pack_string(destination, span.service_type); }); + if (!result) return result; + + if (has_links) { + result = msgpack::pack_string(destination, "span_links"); + if (!result) return result; + result = msgpack::pack_array( + destination, span.span_links, + [](std::string& destination, const SpanLink& link) { + return msgpack_encode(destination, link); + }); + if (!result) return result; + } // clang-format on return nullopt; diff --git a/src/datadog/span_data.h b/src/datadog/span_data.h index 9413e173b..efbda770e 100644 --- a/src/datadog/span_data.h +++ b/src/datadog/span_data.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -33,6 +34,7 @@ struct SpanData { bool error = false; std::unordered_map tags; std::unordered_map numeric_tags; + std::vector span_links; Optional environment() const; Optional version() const; diff --git a/src/datadog/span_link.cpp b/src/datadog/span_link.cpp new file mode 100644 index 000000000..b3b1c91a5 --- /dev/null +++ b/src/datadog/span_link.cpp @@ -0,0 +1,71 @@ +#include + +#include +#include + +#include "msgpack.h" + +namespace datadog { +namespace tracing { + +Expected msgpack_encode(std::string& destination, const SpanLink& link) { + const bool has_trace_id_high = link.trace_id.high != 0; + const bool has_attributes = !link.attributes.empty(); + const bool has_tracestate = link.tracestate && !link.tracestate->empty(); + const bool has_flags = link.flags.has_value(); + + std::size_t size = 2; // trace_id + span_id are always present + if (has_trace_id_high) ++size; + if (has_attributes) ++size; + if (has_tracestate) ++size; + if (has_flags) ++size; + + auto result = msgpack::pack_map(destination, size); + if (!result) return result; + + // trace_id (low 64 bits) + result = msgpack::pack_string(destination, "trace_id"); + if (!result) return result; + msgpack::pack_integer(destination, link.trace_id.low); + + if (has_trace_id_high) { + result = msgpack::pack_string(destination, "trace_id_high"); + if (!result) return result; + msgpack::pack_integer(destination, link.trace_id.high); + } + + result = msgpack::pack_string(destination, "span_id"); + if (!result) return result; + msgpack::pack_integer(destination, link.span_id); + + if (has_attributes) { + result = msgpack::pack_string(destination, "attributes"); + if (!result) return result; + result = + msgpack::pack_map(destination, link.attributes, + [](std::string& destination, const auto& value) { + return msgpack::pack_string(destination, value); + }); + if (!result) return result; + } + + if (has_tracestate) { + result = msgpack::pack_string(destination, "tracestate"); + if (!result) return result; + result = msgpack::pack_string(destination, *link.tracestate); + if (!result) return result; + } + + if (has_flags) { + result = msgpack::pack_string(destination, "flags"); + if (!result) return result; + // The high bit marks "flags is present" so a receiver can distinguish an + // explicit value of 0 from an omitted field. + msgpack::pack_integer(destination, std::uint64_t(*link.flags | (1u << 31))); + } + + return nullopt; +} + +} // namespace tracing +} // namespace datadog diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7571aa8b7..1496765d2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,6 +33,7 @@ add_executable(tests test_parse_util.cpp test_smoke.cpp test_span.cpp + test_span_link.cpp test_span_sampler.cpp test_trace_id.cpp test_trace_segment.cpp diff --git a/test/system-tests/main.cpp b/test/system-tests/main.cpp index de7d421e1..18ba58466 100644 --- a/test/system-tests/main.cpp +++ b/test/system-tests/main.cpp @@ -128,6 +128,10 @@ int main(int argc, char* argv[]) { [&handler](const httplib::Request& req, httplib::Response& res) { handler.on_manual_keep(req, res); }); + svr.Post("/trace/span/add_link", + [&handler](const httplib::Request& req, httplib::Response& res) { + handler.on_add_link(req, res); + }); // Not implemented svr.Post("/trace/span/set_metric", diff --git a/test/system-tests/request_handler.cpp b/test/system-tests/request_handler.cpp index df6055837..ca2824d9d 100644 --- a/test/system-tests/request_handler.cpp +++ b/test/system-tests/request_handler.cpp @@ -1,8 +1,10 @@ #include "request_handler.h" +#include #include #include #include +#include #include #include #include @@ -236,6 +238,90 @@ void RequestHandler::on_set_meta(const httplib::Request& req, res.status = 200; } +void RequestHandler::on_add_link(const httplib::Request& req, + httplib::Response& res) { + const auto request_json = nlohmann::json::parse(req.body); + + auto span_id = utils::get_if_exists(request_json, "span_id"); + if (!span_id) { + VALIDATION_ERROR(res, "on_add_link: missing `span_id` field."); + } + + auto parent_id = utils::get_if_exists(request_json, "parent_id"); + if (!parent_id) { + VALIDATION_ERROR(res, "on_add_link: missing `parent_id` field."); + } + + auto span_it = active_spans_.find(*span_id); + if (span_it == active_spans_.cend()) { + const auto msg = + "on_add_link: span not found for id " + std::to_string(*span_id); + VALIDATION_ERROR(res, msg); + } + + // Parse optional attributes, supporting flat strings and arrays. + datadog::tracing::SpanLinkAttributes attrs; + if (auto attributes = request_json.find("attributes"); + attributes != request_json.cend() && attributes->is_object()) { + for (const auto& [key, value] : attributes->items()) { + if (value.is_string()) { + attrs.emplace(key, value.get()); + } else if (value.is_array()) { + std::size_t idx = 0; + for (const auto& elem : value) { + std::string flat_key = key + "." + std::to_string(idx++); + if (elem.is_string()) { + attrs.emplace(flat_key, elem.get()); + } else if (elem.is_boolean()) { + attrs.emplace(flat_key, elem.get() ? "true" : "false"); + } else if (elem.is_number_integer()) { + attrs.emplace(flat_key, std::to_string(elem.get())); + } else if (elem.is_number_float()) { + attrs.emplace(flat_key, std::to_string(elem.get())); + } + } + } + } + } + + // The linked context is either another active span or a previously extracted + // propagation context, both keyed by `parent_id`. + auto linked_it = active_spans_.find(*parent_id); + if (linked_it != active_spans_.cend()) { + // Propagate sampling priority from the linked span's trace. + nlohmann::json linked_hdrs = nlohmann::json::array(); + utils::HeaderWriter linked_writer(linked_hdrs); + linked_it->second.inject(linked_writer); + for (const auto& hdr : linked_hdrs) { + if (hdr.size() == 2 && + hdr[0].get() == "x-datadog-sampling-priority") { + try { + span_it->second.trace_segment().override_sampling_priority( + std::stoi(hdr[1].get())); + } catch (...) { + } + break; + } + } + span_it->second.add_link(linked_it->second, attrs); + } else { + auto context_it = link_contexts_.find(*parent_id); + if (context_it == link_contexts_.cend()) { + const auto msg = "on_add_link: linked context not found for parent_id " + + std::to_string(*parent_id); + VALIDATION_ERROR(res, msg); + } + const auto& ctx = context_it->second; + if (ctx.sampling_priority.has_value()) { + span_it->second.trace_segment().override_sampling_priority( + *ctx.sampling_priority); + } + span_it->second.add_link(ctx, attrs); + } + + res.status = 200; +} + void RequestHandler::on_set_metric(const httplib::Request& req, httplib::Response& res) { const auto request_json = nlohmann::json::parse(req.body); @@ -345,18 +431,41 @@ void RequestHandler::on_extract_headers(const httplib::Request& req, auto span = tracer_.extract_span(utils::HeaderReader(*http_headers)); if (span.if_error()) { - const auto response_body_fail = nlohmann::json{ - {"span_id", nullptr}, - }; + const auto response_body_fail = nlohmann::json{{"span_id", nullptr}}; res.set_content(response_body_fail.dump(), "application/json"); return; } - const auto response_body = nlohmann::json{ - {"span_id", span->parent_id().value()}, - }; + const auto upstream_id = span->parent_id().value_or(0); + datadog::tracing::ExtractedContext ctx; + ctx.trace_id = span->trace_id(); + ctx.span_id = upstream_id; + for (const auto& hdr : *http_headers) { + if (hdr.size() != 2) continue; + const auto name = hdr[0].get(); + if (name == "tracestate") { + ctx.tracestate = hdr[1].get(); + } else if (name == "traceparent") { + const auto tp = hdr[1].get(); + const auto pos = tp.rfind('-'); + if (pos != std::string::npos && pos + 1 < tp.size()) { + try { + ctx.flags = static_cast( + std::stoul(tp.substr(pos + 1), nullptr, 16)); + } catch (...) { + } + } + } else if (name == "x-datadog-sampling-priority") { + try { + ctx.sampling_priority = std::stoi(hdr[1].get()); + } catch (...) { + } + } + } - tracing_context_[*span->parent_id()] = std::move(*http_headers); + const auto response_body = nlohmann::json{{"span_id", upstream_id}}; + tracing_context_[upstream_id] = std::move(*http_headers); + link_contexts_[upstream_id] = ctx; // The span below will not be finished and flushed. blackhole_.emplace_back(std::move(*span)); @@ -369,6 +478,7 @@ void RequestHandler::on_span_flush(const httplib::Request& /* req */, scheduler_->flush_telemetry(); active_spans_.clear(); tracing_context_.clear(); + link_contexts_.clear(); res.status = 200; } diff --git a/test/system-tests/request_handler.h b/test/system-tests/request_handler.h index 09baf9753..d30924415 100644 --- a/test/system-tests/request_handler.h +++ b/test/system-tests/request_handler.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -24,6 +25,7 @@ class RequestHandler final { void on_span_start(const httplib::Request& req, httplib::Response& res); void on_span_end(const httplib::Request& req, httplib::Response& res); void on_set_meta(const httplib::Request& req, httplib::Response& res); + void on_add_link(const httplib::Request& req, httplib::Response& res); void on_set_metric(const httplib::Request& /* req */, httplib::Response& res); void on_manual_keep(const httplib::Request& req, httplib::Response& res); void on_manual_drop(const httplib::Request& req, httplib::Response& res); @@ -40,6 +42,8 @@ class RequestHandler final { std::shared_ptr logger_; std::unordered_map active_spans_; std::unordered_map tracing_context_; + std::unordered_map + link_contexts_; // Previously, `/trace/span/start` was used to create new spans or create // child spans from the extracted tracing context. diff --git a/test/test_span.cpp b/test/test_span.cpp index 4af524ed7..dd4c94d78 100644 --- a/test/test_span.cpp +++ b/test/test_span.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -1084,3 +1085,46 @@ TEST_SPAN("injection behaviour when apm tracing is disabled") { CHECK(contains_tracing_context(writer.items)); } } + +TEST_SPAN("add_link records links on the span data") { + TracerConfig config; + config.service = "testsvc"; + const auto collector = std::make_shared(); + config.collector = collector; + config.logger = std::make_shared(); + + auto finalized_config = finalize_config(config); + REQUIRE(finalized_config); + Tracer tracer{*finalized_config}; + + TraceID linked_trace_id; + std::uint64_t linked_span_id = 0; + + { + auto span = tracer.create_span(); + auto linked = tracer.create_span(); + linked_trace_id = linked.trace_id(); + linked_span_id = linked.id(); + + const SpanLinkAttributes attrs{{"link.key", "value"}}; + span.add_link(linked, attrs); + } + + // Find the span that carries the link across all chunks. + const SpanData* span_with_link = nullptr; + for (const auto& chunk : collector->chunks) { + for (const auto& sd : chunk) { + if (!sd->span_links.empty()) { + span_with_link = sd.get(); + } + } + } + REQUIRE(span_with_link != nullptr); + REQUIRE(span_with_link->span_links.size() == 1); + const auto& link = span_with_link->span_links[0]; + REQUIRE(link.trace_id == linked_trace_id); + REQUIRE(link.span_id == linked_span_id); + REQUIRE(link.attributes.at("link.key") == "value"); + // traceparent is always injected with W3C propagation, so flags must be set. + REQUIRE(link.flags.has_value()); +} diff --git a/test/test_span_link.cpp b/test/test_span_link.cpp new file mode 100644 index 000000000..8fbf9cb04 --- /dev/null +++ b/test/test_span_link.cpp @@ -0,0 +1,156 @@ +// Tests for `SpanLink` msgpack serialization. The on-the-wire field names and +// omission rules must match the other Datadog tracers (dd-trace-go, -py, -rs). + +#include + +#include +#include +#include + +#include "test.h" + +using namespace datadog::tracing; + +#define TEST_SPAN_LINK(x) TEST_CASE(x, "[span_link]") + +namespace { +// Encode a single link and decode it back to JSON for inspection. +nlohmann::json encode_to_json(const SpanLink& link) { + std::string buffer; + const auto result = msgpack_encode(buffer, link); + REQUIRE(result); + return nlohmann::json::from_msgpack(buffer); +} +} // namespace + +TEST_SPAN_LINK("minimal link encodes only trace_id and span_id") { + SpanLink link; + link.trace_id = TraceID(0x1122334455667788ULL); // high == 0 + link.span_id = 42; + + const auto j = encode_to_json(link); + + REQUIRE(j.is_object()); + REQUIRE(j.size() == 2); + REQUIRE(j["trace_id"].get() == 0x1122334455667788ULL); + REQUIRE(j["span_id"].get() == 42); + REQUIRE_FALSE(j.contains("trace_id_high")); + REQUIRE_FALSE(j.contains("attributes")); + REQUIRE_FALSE(j.contains("tracestate")); + REQUIRE_FALSE(j.contains("flags")); +} + +TEST_SPAN_LINK("128-bit trace id emits trace_id_high") { + SpanLink link; + link.trace_id = TraceID(/*low=*/0xAAAAAAAAAAAAAAAAULL, + /*high=*/0xBBBBBBBBBBBBBBBBULL); + link.span_id = 7; + + const auto j = encode_to_json(link); + + REQUIRE(j["trace_id"].get() == 0xAAAAAAAAAAAAAAAAULL); + REQUIRE(j["trace_id_high"].get() == 0xBBBBBBBBBBBBBBBBULL); + REQUIRE(j["span_id"].get() == 7); +} + +TEST_SPAN_LINK("attributes encode as a string map and omit when empty") { + SpanLink link; + link.trace_id = TraceID(1); + link.span_id = 2; + + SECTION("present") { + link.attributes = {{"link.key", "value"}, {"k2", "v2"}}; + const auto j = encode_to_json(link); + REQUIRE(j["attributes"]["link.key"].get() == "value"); + REQUIRE(j["attributes"]["k2"].get() == "v2"); + } + + SECTION("empty -> omitted") { + const auto j = encode_to_json(link); + REQUIRE_FALSE(j.contains("attributes")); + } +} + +TEST_SPAN_LINK("tracestate omitted when empty, present otherwise") { + SpanLink link; + link.trace_id = TraceID(1); + link.span_id = 2; + + SECTION("non-empty") { + link.tracestate = "dd=s:1"; + const auto j = encode_to_json(link); + REQUIRE(j["tracestate"].get() == "dd=s:1"); + } + + SECTION("empty string -> omitted") { + link.tracestate = ""; + const auto j = encode_to_json(link); + REQUIRE_FALSE(j.contains("tracestate")); + } + + SECTION("unset -> omitted") { + const auto j = encode_to_json(link); + REQUIRE_FALSE(j.contains("tracestate")); + } +} + +TEST_SPAN_LINK("flags set the high bit when present") { + SpanLink link; + link.trace_id = TraceID(1); + link.span_id = 2; + + SECTION("sampled") { + link.flags = 1u; + const auto j = encode_to_json(link); + REQUIRE(j["flags"].get() == (1u | (1u << 31))); + } + + SECTION("zero is still present with high bit") { + link.flags = 0u; + const auto j = encode_to_json(link); + REQUIRE(j["flags"].get() == (1u << 31)); + } + + SECTION("unset -> omitted") { + const auto j = encode_to_json(link); + REQUIRE_FALSE(j.contains("flags")); + } +} + +#include "span_data.h" // internal header; available via test include dirs + +TEST_SPAN_LINK("SpanData omits span_links when there are none") { + SpanData span; + std::string buffer; + const auto result = msgpack_encode(buffer, span); + REQUIRE(result); + + const auto j = nlohmann::json::from_msgpack(buffer); + REQUIRE(j.is_object()); + REQUIRE_FALSE(j.contains("span_links")); +} + +TEST_SPAN_LINK("SpanData emits span_links array when present") { + SpanData span; + + SpanLink link; + link.trace_id = TraceID(/*low=*/0x99, /*high=*/0x11); + link.span_id = 123; + link.attributes = {{"link.key", "value"}}; + span.span_links.push_back(link); + + std::string buffer; + const auto result = msgpack_encode(buffer, span); + REQUIRE(result); + + const auto j = nlohmann::json::from_msgpack(buffer); + REQUIRE(j.contains("span_links")); + REQUIRE(j["span_links"].is_array()); + REQUIRE(j["span_links"].size() == 1); + + const auto& encoded = j["span_links"][0]; + REQUIRE(encoded["trace_id"].get() == 0x99); + REQUIRE(encoded["trace_id_high"].get() == 0x11); + REQUIRE(encoded["span_id"].get() == 123); + REQUIRE(encoded["attributes"]["link.key"].get() == "value"); +}