Skip to content

Commit 7c46b8d

Browse files
committed
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.
1 parent b0dee54 commit 7c46b8d

4 files changed

Lines changed: 263 additions & 5 deletions

File tree

lib/sentry/interfaces.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,8 @@ defmodule Sentry.Interfaces do
291291
status: String.t() | nil,
292292
tags: %{optional(String.t()) => term()} | nil,
293293
data: %{optional(String.t()) => term()} | nil,
294-
origin: String.t() | nil
294+
origin: String.t() | nil,
295+
links: [map()] | nil
295296
}
296297

297298
@enforce_keys [:trace_id, :span_id, :start_timestamp, :timestamp]
@@ -304,7 +305,8 @@ defmodule Sentry.Interfaces do
304305
:status,
305306
:tags,
306307
:data,
307-
:origin
308+
:origin,
309+
:links
308310
]
309311
end
310312
end

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
137137
defp build_trace_context(span_record) do
138138
{op, description} = get_op_description(span_record)
139139

140-
%{
140+
context = %{
141141
trace_id: span_record.trace_id,
142142
span_id: span_record.span_id,
143143
parent_span_id: span_record.parent_span_id,
@@ -146,6 +146,13 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
146146
origin: span_record.origin,
147147
data: filter_attributes(span_record.attributes)
148148
}
149+
150+
# Add links if present (for root spans, links go in trace context)
151+
if span_record.links != [] do
152+
Map.put(context, :links, format_links(span_record.links))
153+
else
154+
context
155+
end
149156
end
150157

151158
defp get_op_description(
@@ -204,7 +211,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
204211

205212
filtered_attributes = filter_attributes(span_record.attributes)
206213

207-
%Span{
214+
span = %Span{
208215
op: op,
209216
description: description,
210217
start_timestamp: span_record.start_time,
@@ -216,6 +223,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
216223
data: Map.put(filtered_attributes, "otel.kind", span_record.kind),
217224
status: span_status(span_record)
218225
}
226+
227+
# Add links if present (for child spans, links go in the span itself).
228+
# When links is empty, the span retains links: nil (struct default), which is
229+
# consistent with how other optional Span fields (status, tags, op) are handled —
230+
# they are also sent as null via Map.from_struct/1 in Transaction.to_payload/1.
231+
if span_record.links != [] do
232+
%{span | links: format_links(span_record.links)}
233+
else
234+
span
235+
end
219236
end
220237

221238
defp span_status(%{
@@ -264,5 +281,28 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
264281
end)
265282
|> Map.new()
266283
end
284+
285+
# Format span links according to Sentry spec
286+
# https://develop.sentry.dev/sdk/telemetry/traces/span-links/
287+
#
288+
# Note: The spec defines an optional `sampled` boolean, but the OTel link record
289+
# only exposes `tracestate` (vendor key-value pairs), not `trace_flags` (which
290+
# contains the sampled bit). The sampled field cannot be extracted from the
291+
# current OTel Erlang SDK link record structure.
292+
defp format_links(links) do
293+
Enum.map(links, fn link ->
294+
formatted = %{
295+
span_id: link.span_id,
296+
trace_id: link.trace_id
297+
}
298+
299+
# Add attributes if present
300+
if map_size(link.attributes) > 0 do
301+
Map.put(formatted, :attributes, link.attributes)
302+
else
303+
formatted
304+
end
305+
end)
306+
end
267307
end
268308
end

lib/sentry/opentelemetry/span_record.ex

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
2929
attrs =
3030
otel_attrs
3131
|> Keyword.delete(:attributes)
32+
|> Keyword.delete(:links)
3233
|> Keyword.merge(
3334
trace_id: cast_trace_id(otel_attrs[:trace_id]),
3435
span_id: cast_span_id(otel_attrs[:span_id]),
3536
parent_span_id: cast_span_id(otel_attrs[:parent_span_id]),
3637
origin: origin,
3738
start_time: cast_timestamp(otel_attrs[:start_time]),
3839
end_time: cast_timestamp(otel_attrs[:end_time]),
39-
attributes: normalize_attributes(attributes)
40+
attributes: normalize_attributes(attributes),
41+
links: cast_links(otel_attrs[:links])
4042
)
4143
|> Map.new()
4244

@@ -66,6 +68,28 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
6668
DateTime.to_iso8601(datetime)
6769
end
6870

71+
defp cast_links(
72+
{:links, _count_limit, _attr_per_link_limit, _attr_value_length_limit, _dropped,
73+
links_list}
74+
) do
75+
Enum.map(links_list, fn link ->
76+
case link do
77+
{:link, trace_id, span_id, {:attributes, _, _, _, attributes}, _tracestate} ->
78+
%{
79+
trace_id: cast_trace_id(trace_id),
80+
span_id: cast_span_id(span_id),
81+
attributes: normalize_attributes(attributes)
82+
}
83+
84+
_ ->
85+
nil
86+
end
87+
end)
88+
|> Enum.reject(&is_nil/1)
89+
end
90+
91+
defp cast_links(_), do: []
92+
6993
defp bytes_to_hex(bytes, length) do
7094
case(:otel_utils.format_binary_string("~#{length}.16.0b", [bytes])) do
7195
{:ok, result} -> result

test/sentry/opentelemetry/span_processor_test.exs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,4 +821,196 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do
821821
refute SpanStorage.span_exists?("completed_child", table_name: table_name)
822822
end
823823
end
824+
825+
describe "span links" do
826+
@tag span_storage: true
827+
test "root span with links includes links in trace context" do
828+
put_test_config(environment_name: "test", traces_sample_rate: 1.0)
829+
Sentry.Test.start_collecting_sentry_reports()
830+
831+
# Create a source span and capture its context
832+
source_ctx =
833+
Tracer.with_span "source_span" do
834+
OpenTelemetry.Tracer.current_span_ctx()
835+
end
836+
837+
link = OpenTelemetry.link(source_ctx)
838+
839+
# Create a new root span with a link to the source span
840+
Tracer.with_span "GET /api/linked", %{
841+
kind: :server,
842+
attributes: %{
843+
HTTPAttributes.http_request_method() => :GET,
844+
URLAttributes.url_path() => "/api/linked"
845+
},
846+
links: [link]
847+
} do
848+
Process.sleep(10)
849+
end
850+
851+
transactions = Sentry.Test.pop_sentry_transactions()
852+
853+
linked_tx =
854+
Enum.find(transactions, fn tx -> tx.transaction == "GET /api/linked" end)
855+
856+
assert linked_tx != nil
857+
858+
trace_links = linked_tx.contexts.trace.links
859+
assert is_list(trace_links)
860+
assert length(trace_links) == 1
861+
862+
[span_link] = trace_links
863+
assert String.match?(span_link.trace_id, ~r/^[a-f0-9]{32}$/)
864+
assert String.match?(span_link.span_id, ~r/^[a-f0-9]{16}$/)
865+
refute Map.has_key?(span_link, :attributes)
866+
end
867+
868+
@tag span_storage: true
869+
test "root span with links preserves link attributes" do
870+
put_test_config(environment_name: "test", traces_sample_rate: 1.0)
871+
Sentry.Test.start_collecting_sentry_reports()
872+
873+
source_ctx =
874+
Tracer.with_span "source_span" do
875+
OpenTelemetry.Tracer.current_span_ctx()
876+
end
877+
878+
link = OpenTelemetry.link(source_ctx, %{"my.key" => "my.value"})
879+
880+
Tracer.with_span "GET /api/linked", %{
881+
kind: :server,
882+
attributes: %{
883+
HTTPAttributes.http_request_method() => :GET,
884+
URLAttributes.url_path() => "/api/linked"
885+
},
886+
links: [link]
887+
} do
888+
Process.sleep(10)
889+
end
890+
891+
transactions = Sentry.Test.pop_sentry_transactions()
892+
893+
linked_tx =
894+
Enum.find(transactions, fn tx -> tx.transaction == "GET /api/linked" end)
895+
896+
[span_link] = linked_tx.contexts.trace.links
897+
assert span_link.attributes == %{"my.key" => "my.value"}
898+
end
899+
900+
@tag span_storage: true
901+
test "child span with links includes links in the span struct" do
902+
put_test_config(environment_name: "test", traces_sample_rate: 1.0)
903+
Sentry.Test.start_collecting_sentry_reports()
904+
905+
source_ctx =
906+
Tracer.with_span "source_span" do
907+
OpenTelemetry.Tracer.current_span_ctx()
908+
end
909+
910+
link = OpenTelemetry.link(source_ctx)
911+
912+
Tracer.with_span "GET /api/parent", %{
913+
kind: :server,
914+
attributes: %{
915+
HTTPAttributes.http_request_method() => :GET,
916+
URLAttributes.url_path() => "/api/parent"
917+
}
918+
} do
919+
Tracer.with_span "child_with_link", %{links: [link]} do
920+
Process.sleep(10)
921+
end
922+
end
923+
924+
transactions = Sentry.Test.pop_sentry_transactions()
925+
926+
parent_tx =
927+
Enum.find(transactions, fn tx -> tx.transaction == "GET /api/parent" end)
928+
929+
assert length(parent_tx.spans) == 1
930+
[child_span] = parent_tx.spans
931+
932+
assert is_list(child_span.links)
933+
assert length(child_span.links) == 1
934+
935+
[span_link] = child_span.links
936+
assert String.match?(span_link.trace_id, ~r/^[a-f0-9]{32}$/)
937+
assert String.match?(span_link.span_id, ~r/^[a-f0-9]{16}$/)
938+
end
939+
940+
@tag span_storage: true
941+
test "spans without links have nil links" do
942+
put_test_config(environment_name: "test", traces_sample_rate: 1.0)
943+
Sentry.Test.start_collecting_sentry_reports()
944+
945+
Tracer.with_span "GET /api/no-links", %{
946+
kind: :server,
947+
attributes: %{
948+
HTTPAttributes.http_request_method() => :GET,
949+
URLAttributes.url_path() => "/api/no-links"
950+
}
951+
} do
952+
Tracer.with_span "child_span" do
953+
Process.sleep(10)
954+
end
955+
end
956+
957+
[transaction] = Sentry.Test.pop_sentry_transactions()
958+
959+
refute Map.has_key?(transaction.contexts.trace, :links)
960+
assert [child_span] = transaction.spans
961+
assert child_span.links == nil
962+
end
963+
964+
@tag span_storage: true
965+
test "span with multiple links preserves all links" do
966+
put_test_config(environment_name: "test", traces_sample_rate: 1.0)
967+
Sentry.Test.start_collecting_sentry_reports()
968+
969+
source_ctx_1 =
970+
Tracer.with_span "source_1" do
971+
OpenTelemetry.Tracer.current_span_ctx()
972+
end
973+
974+
source_ctx_2 =
975+
Tracer.with_span "source_2" do
976+
OpenTelemetry.Tracer.current_span_ctx()
977+
end
978+
979+
link_1 = OpenTelemetry.link(source_ctx_1)
980+
link_2 = OpenTelemetry.link(source_ctx_2, %{"order" => "second"})
981+
982+
Tracer.with_span "GET /api/multi-linked", %{
983+
kind: :server,
984+
attributes: %{
985+
HTTPAttributes.http_request_method() => :GET,
986+
URLAttributes.url_path() => "/api/multi-linked"
987+
},
988+
links: [link_1, link_2]
989+
} do
990+
Process.sleep(10)
991+
end
992+
993+
transactions = Sentry.Test.pop_sentry_transactions()
994+
995+
linked_tx =
996+
Enum.find(transactions, fn tx -> tx.transaction == "GET /api/multi-linked" end)
997+
998+
trace_links = linked_tx.contexts.trace.links
999+
assert length(trace_links) == 2
1000+
1001+
# Both links should have valid trace/span IDs
1002+
Enum.each(trace_links, fn link ->
1003+
assert String.match?(link.trace_id, ~r/^[a-f0-9]{32}$/)
1004+
assert String.match?(link.span_id, ~r/^[a-f0-9]{16}$/)
1005+
end)
1006+
1007+
# The two links should point to different spans
1008+
span_ids = Enum.map(trace_links, & &1.span_id)
1009+
assert length(Enum.uniq(span_ids)) == 2
1010+
1011+
# The link with attributes should preserve them
1012+
link_with_attrs = Enum.find(trace_links, &Map.has_key?(&1, :attributes))
1013+
assert link_with_attrs.attributes == %{"order" => "second"}
1014+
end
1015+
end
8241016
end

0 commit comments

Comments
 (0)