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