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
6 changes: 4 additions & 2 deletions lib/sentry/interfaces.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -304,7 +305,8 @@ defmodule Sentry.Interfaces do
:status,
:tags,
:data,
:origin
:origin,
:links
]
end
end
44 changes: 42 additions & 2 deletions lib/sentry/opentelemetry/span_processor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(%{
Expand Down Expand Up @@ -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
26 changes: 25 additions & 1 deletion lib/sentry/opentelemetry/span_record.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ 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]),
parent_span_id: cast_span_id(otel_attrs[:parent_span_id]),
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()

Expand Down Expand Up @@ -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
Expand Down
192 changes: 192 additions & 0 deletions test/sentry/opentelemetry/span_processor_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading