From 7c46b8db03c32fc3792280d5837d58b98211e36f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 20 Mar 2026 07:08:45 +0000 Subject: [PATCH] feat(tracing): add support for OTel span links Parse span links from OpenTelemetry span records and include them in both trace context (for root spans) and individual spans (for child spans), following the Sentry span links spec. --- lib/sentry/interfaces.ex | 6 +- lib/sentry/opentelemetry/span_processor.ex | 44 +++- lib/sentry/opentelemetry/span_record.ex | 26 ++- .../opentelemetry/span_processor_test.exs | 192 ++++++++++++++++++ 4 files changed, 263 insertions(+), 5 deletions(-) diff --git a/lib/sentry/interfaces.ex b/lib/sentry/interfaces.ex index eeb18b2d..efc43344 100644 --- a/lib/sentry/interfaces.ex +++ b/lib/sentry/interfaces.ex @@ -291,7 +291,8 @@ defmodule Sentry.Interfaces do status: String.t() | nil, tags: %{optional(String.t()) => term()} | nil, data: %{optional(String.t()) => term()} | nil, - origin: String.t() | nil + origin: String.t() | nil, + links: [map()] | nil } @enforce_keys [:trace_id, :span_id, :start_timestamp, :timestamp] @@ -304,7 +305,8 @@ defmodule Sentry.Interfaces do :status, :tags, :data, - :origin + :origin, + :links ] end end diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 277b6861..b51c036f 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -137,7 +137,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do defp build_trace_context(span_record) do {op, description} = get_op_description(span_record) - %{ + context = %{ trace_id: span_record.trace_id, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id, @@ -146,6 +146,13 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do origin: span_record.origin, data: filter_attributes(span_record.attributes) } + + # Add links if present (for root spans, links go in trace context) + if span_record.links != [] do + Map.put(context, :links, format_links(span_record.links)) + else + context + end end defp get_op_description( @@ -204,7 +211,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do filtered_attributes = filter_attributes(span_record.attributes) - %Span{ + span = %Span{ op: op, description: description, start_timestamp: span_record.start_time, @@ -216,6 +223,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do data: Map.put(filtered_attributes, "otel.kind", span_record.kind), status: span_status(span_record) } + + # Add links if present (for child spans, links go in the span itself). + # When links is empty, the span retains links: nil (struct default), which is + # consistent with how other optional Span fields (status, tags, op) are handled — + # they are also sent as null via Map.from_struct/1 in Transaction.to_payload/1. + if span_record.links != [] do + %{span | links: format_links(span_record.links)} + else + span + end end defp span_status(%{ @@ -264,5 +281,28 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do end) |> Map.new() end + + # Format span links according to Sentry spec + # https://develop.sentry.dev/sdk/telemetry/traces/span-links/ + # + # Note: The spec defines an optional `sampled` boolean, but the OTel link record + # only exposes `tracestate` (vendor key-value pairs), not `trace_flags` (which + # contains the sampled bit). The sampled field cannot be extracted from the + # current OTel Erlang SDK link record structure. + defp format_links(links) do + Enum.map(links, fn link -> + formatted = %{ + span_id: link.span_id, + trace_id: link.trace_id + } + + # Add attributes if present + if map_size(link.attributes) > 0 do + Map.put(formatted, :attributes, link.attributes) + else + formatted + end + end) + end end end diff --git a/lib/sentry/opentelemetry/span_record.ex b/lib/sentry/opentelemetry/span_record.ex index cd2a25d2..f2373bc3 100644 --- a/lib/sentry/opentelemetry/span_record.ex +++ b/lib/sentry/opentelemetry/span_record.ex @@ -29,6 +29,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do attrs = otel_attrs |> Keyword.delete(:attributes) + |> Keyword.delete(:links) |> Keyword.merge( trace_id: cast_trace_id(otel_attrs[:trace_id]), span_id: cast_span_id(otel_attrs[:span_id]), @@ -36,7 +37,8 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do origin: origin, start_time: cast_timestamp(otel_attrs[:start_time]), end_time: cast_timestamp(otel_attrs[:end_time]), - attributes: normalize_attributes(attributes) + attributes: normalize_attributes(attributes), + links: cast_links(otel_attrs[:links]) ) |> Map.new() @@ -66,6 +68,28 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do DateTime.to_iso8601(datetime) end + defp cast_links( + {:links, _count_limit, _attr_per_link_limit, _attr_value_length_limit, _dropped, + links_list} + ) do + Enum.map(links_list, fn link -> + case link do + {:link, trace_id, span_id, {:attributes, _, _, _, attributes}, _tracestate} -> + %{ + trace_id: cast_trace_id(trace_id), + span_id: cast_span_id(span_id), + attributes: normalize_attributes(attributes) + } + + _ -> + nil + end + end) + |> Enum.reject(&is_nil/1) + end + + defp cast_links(_), do: [] + defp bytes_to_hex(bytes, length) do case(:otel_utils.format_binary_string("~#{length}.16.0b", [bytes])) do {:ok, result} -> result diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index bb402299..f548ed7c 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -821,4 +821,196 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do refute SpanStorage.span_exists?("completed_child", table_name: table_name) end end + + describe "span links" do + @tag span_storage: true + test "root span with links includes links in trace context" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + Sentry.Test.start_collecting_sentry_reports() + + # Create a source span and capture its context + source_ctx = + Tracer.with_span "source_span" do + OpenTelemetry.Tracer.current_span_ctx() + end + + link = OpenTelemetry.link(source_ctx) + + # Create a new root span with a link to the source span + Tracer.with_span "GET /api/linked", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :GET, + URLAttributes.url_path() => "/api/linked" + }, + links: [link] + } do + Process.sleep(10) + end + + transactions = Sentry.Test.pop_sentry_transactions() + + linked_tx = + Enum.find(transactions, fn tx -> tx.transaction == "GET /api/linked" end) + + assert linked_tx != nil + + trace_links = linked_tx.contexts.trace.links + assert is_list(trace_links) + assert length(trace_links) == 1 + + [span_link] = trace_links + assert String.match?(span_link.trace_id, ~r/^[a-f0-9]{32}$/) + assert String.match?(span_link.span_id, ~r/^[a-f0-9]{16}$/) + refute Map.has_key?(span_link, :attributes) + end + + @tag span_storage: true + test "root span with links preserves link attributes" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + Sentry.Test.start_collecting_sentry_reports() + + source_ctx = + Tracer.with_span "source_span" do + OpenTelemetry.Tracer.current_span_ctx() + end + + link = OpenTelemetry.link(source_ctx, %{"my.key" => "my.value"}) + + Tracer.with_span "GET /api/linked", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :GET, + URLAttributes.url_path() => "/api/linked" + }, + links: [link] + } do + Process.sleep(10) + end + + transactions = Sentry.Test.pop_sentry_transactions() + + linked_tx = + Enum.find(transactions, fn tx -> tx.transaction == "GET /api/linked" end) + + [span_link] = linked_tx.contexts.trace.links + assert span_link.attributes == %{"my.key" => "my.value"} + end + + @tag span_storage: true + test "child span with links includes links in the span struct" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + Sentry.Test.start_collecting_sentry_reports() + + source_ctx = + Tracer.with_span "source_span" do + OpenTelemetry.Tracer.current_span_ctx() + end + + link = OpenTelemetry.link(source_ctx) + + Tracer.with_span "GET /api/parent", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :GET, + URLAttributes.url_path() => "/api/parent" + } + } do + Tracer.with_span "child_with_link", %{links: [link]} do + Process.sleep(10) + end + end + + transactions = Sentry.Test.pop_sentry_transactions() + + parent_tx = + Enum.find(transactions, fn tx -> tx.transaction == "GET /api/parent" end) + + assert length(parent_tx.spans) == 1 + [child_span] = parent_tx.spans + + assert is_list(child_span.links) + assert length(child_span.links) == 1 + + [span_link] = child_span.links + assert String.match?(span_link.trace_id, ~r/^[a-f0-9]{32}$/) + assert String.match?(span_link.span_id, ~r/^[a-f0-9]{16}$/) + end + + @tag span_storage: true + test "spans without links have nil links" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + Sentry.Test.start_collecting_sentry_reports() + + Tracer.with_span "GET /api/no-links", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :GET, + URLAttributes.url_path() => "/api/no-links" + } + } do + Tracer.with_span "child_span" do + Process.sleep(10) + end + end + + [transaction] = Sentry.Test.pop_sentry_transactions() + + refute Map.has_key?(transaction.contexts.trace, :links) + assert [child_span] = transaction.spans + assert child_span.links == nil + end + + @tag span_storage: true + test "span with multiple links preserves all links" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + Sentry.Test.start_collecting_sentry_reports() + + source_ctx_1 = + Tracer.with_span "source_1" do + OpenTelemetry.Tracer.current_span_ctx() + end + + source_ctx_2 = + Tracer.with_span "source_2" do + OpenTelemetry.Tracer.current_span_ctx() + end + + link_1 = OpenTelemetry.link(source_ctx_1) + link_2 = OpenTelemetry.link(source_ctx_2, %{"order" => "second"}) + + Tracer.with_span "GET /api/multi-linked", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :GET, + URLAttributes.url_path() => "/api/multi-linked" + }, + links: [link_1, link_2] + } do + Process.sleep(10) + end + + transactions = Sentry.Test.pop_sentry_transactions() + + linked_tx = + Enum.find(transactions, fn tx -> tx.transaction == "GET /api/multi-linked" end) + + trace_links = linked_tx.contexts.trace.links + assert length(trace_links) == 2 + + # Both links should have valid trace/span IDs + Enum.each(trace_links, fn link -> + assert String.match?(link.trace_id, ~r/^[a-f0-9]{32}$/) + assert String.match?(link.span_id, ~r/^[a-f0-9]{16}$/) + end) + + # The two links should point to different spans + span_ids = Enum.map(trace_links, & &1.span_id) + assert length(Enum.uniq(span_ids)) == 2 + + # The link with attributes should preserve them + link_with_attrs = Enum.find(trace_links, &Map.has_key?(&1, :attributes)) + assert link_with_attrs.attributes == %{"order" => "second"} + end + end end