diff --git a/lib/sentry/opentelemetry/propagator.ex b/lib/sentry/opentelemetry/propagator.ex new file mode 100644 index 00000000..2b126e0d --- /dev/null +++ b/lib/sentry/opentelemetry/propagator.ex @@ -0,0 +1,153 @@ +if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + defmodule Sentry.OpenTelemetry.Propagator do + @moduledoc """ + OpenTelemetry propagator for Sentry distributed tracing. + + This propagator implements the `sentry-trace` and `sentry-baggage` header propagation + to enable distributed tracing across service boundaries. It follows the W3C Trace Context. + + ## Usage + + Add the propagator to your OpenTelemetry configuration: + + config :opentelemetry, + text_map_propagators: [ + :trace_context, + :baggage, + Sentry.OpenTelemetry.Propagator + ] + + This will enable automatic propagation of Sentry trace context in HTTP headers. + """ + + import Bitwise + + require Record + require OpenTelemetry.Tracer, as: Tracer + + @behaviour :otel_propagator_text_map + + @fields Record.extract(:span_ctx, from_lib: "opentelemetry_api/include/opentelemetry.hrl") + Record.defrecordp(:span_ctx, @fields) + + @sentry_trace_key "sentry-trace" + @sentry_baggage_key "baggage" + @sentry_trace_ctx_key :"sentry-trace" + @sentry_baggage_ctx_key :"sentry-baggage" + + @impl true + def fields(_opts) do + [@sentry_trace_key, @sentry_baggage_key] + end + + @impl true + def inject(ctx, carrier, setter, _opts) do + case Tracer.current_span_ctx(ctx) do + span_ctx(trace_id: tid, span_id: sid, trace_flags: flags) when tid != 0 and sid != 0 -> + sentry_trace_header = encode_sentry_trace({tid, sid, flags}) + carrier = setter.(@sentry_trace_key, sentry_trace_header, carrier) + + # Inject baggage if it exists in the context + # Note: :otel_ctx.get_value/2 returns the key itself if value not found + baggage_value = :otel_ctx.get_value(ctx, @sentry_baggage_ctx_key, :not_found) + + if is_binary(baggage_value) and baggage_value != :not_found do + setter.(@sentry_baggage_key, baggage_value, carrier) + else + carrier + end + + _ -> + carrier + end + end + + @impl true + def extract(ctx, carrier, _keys_fun, getter, _opts) do + case getter.(@sentry_trace_key, carrier) do + :undefined -> + ctx + + header when is_binary(header) -> + case decode_sentry_trace(header) do + {:ok, {trace_hex, span_hex, sampled}} -> + ctx = + ctx + |> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled}) + |> maybe_set_baggage(getter.(@sentry_baggage_key, carrier)) + + trace_id = hex_to_int(trace_hex) + span_id = hex_to_int(span_hex) + + # Always use sampled (1) to simulate a sampled trace on the OTel side + trace_flags = 1 + + remote_span_ctx = + :otel_tracer.from_remote_span(trace_id, span_id, trace_flags) + + Tracer.set_current_span(ctx, remote_span_ctx) + + {:error, _reason} -> + # Invalid header format, skip propagation + ctx + end + + _ -> + ctx + end + end + + # Encode trace ID, span ID, and sampled flag to sentry-trace header format + # Format: {trace_id}-{span_id}-{sampled} + defp encode_sentry_trace({trace_id_int, span_id_int, trace_flags}) do + sampled = if (trace_flags &&& 1) == 1, do: "1", else: "0" + int_to_hex(trace_id_int, 16) <> "-" <> int_to_hex(span_id_int, 8) <> "-" <> sampled + end + + # Decode sentry-trace header + # Format: {trace_id}-{span_id}-{sampled} or {trace_id}-{span_id} + defp decode_sentry_trace( + <> + ) do + {:ok, {trace_hex, span_hex, sampled == "1"}} + end + + defp decode_sentry_trace(<>) do + {:ok, {trace_hex, span_hex, false}} + end + + defp decode_sentry_trace(_invalid) do + {:error, :invalid_format} + end + + defp maybe_set_baggage(ctx, :undefined), do: ctx + defp maybe_set_baggage(ctx, ""), do: ctx + defp maybe_set_baggage(ctx, nil), do: ctx + + defp maybe_set_baggage(ctx, baggage) when is_binary(baggage) do + :otel_ctx.set_value(ctx, @sentry_baggage_ctx_key, baggage) + end + + # Convert hex string to integer + defp hex_to_int(hex) do + hex + |> Base.decode16!(case: :mixed) + |> :binary.decode_unsigned() + end + + # Convert integer to hex string with padding + defp int_to_hex(value, num_bytes) do + value + |> :binary.encode_unsigned() + |> bin_pad_left(num_bytes) + |> Base.encode16(case: :lower) + end + + # Pad binary to specified number of bytes + defp bin_pad_left(bin, total_bytes) do + missing = total_bytes - byte_size(bin) + if missing > 0, do: :binary.copy(<<0>>, missing) <> bin, else: bin + end + end +end diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index c0f81203..3ab79718 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -11,11 +11,15 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do require OpenTelemetry.SemConv.Incubating.MessagingAttributes, as: MessagingAttributes require Logger + require Record alias Sentry.{Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord} alias Sentry.Interfaces.Span - # This can be a no-op since we can postpone inserting the span into storage until on_end + # Extract span record fields to access parent_span_id in on_start + @span_fields Record.extract(:span, from_lib: "opentelemetry/include/otel_span.hrl") + Record.defrecordp(:span, @span_fields) + @impl :otel_span_processor def on_start(_ctx, otel_span, _config) do otel_span @@ -24,35 +28,70 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do @impl :otel_span_processor def on_end(otel_span, _config) do span_record = SpanRecord.new(otel_span) + process_span(span_record) + end + defp process_span(span_record) do SpanStorage.store_span(span_record) - if span_record.parent_span_id == nil do - child_span_records = SpanStorage.get_child_spans(span_record.span_id) - transaction = build_transaction(span_record, child_span_records) + # Check if this is a root span (no parent) or a transaction root + # + # A span should be a transaction root if: + # 1. It has no parent (true root span) + # 2. OR it's a server span with only a REMOTE parent (distributed tracing) + # + # A span should NOT be a transaction root if: + # - It has a LOCAL parent (parent span exists in our SpanStorage) + is_transaction_root = + cond do + # No parent = definitely a root + span_record.parent_span_id == nil -> + true + + # Has a parent - check if it's local or remote + true -> + has_local_parent = has_local_parent_span?(span_record.parent_span_id) + + if has_local_parent do + # Parent exists locally - this is a child span, not a transaction root + false + else + # Parent is remote (distributed tracing) - treat server spans as transaction roots + is_server_span?(span_record) + end + end - result = - case Sentry.send_transaction(transaction) do - {:ok, _id} -> - true + if is_transaction_root do + build_and_send_transaction(span_record) + else + true + end + end - :ignored -> - true + defp build_and_send_transaction(span_record) do + child_span_records = SpanStorage.get_child_spans(span_record.span_id) + transaction = build_transaction(span_record, child_span_records) - :excluded -> - true + result = + case Sentry.send_transaction(transaction) do + {:ok, _id} -> + true - {:error, error} -> - Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}") - {:error, :invalid_span} - end + :ignored -> + true - :ok = SpanStorage.remove_root_span(span_record.span_id) + :excluded -> + true - result - else - true - end + {:error, error} -> + Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}") + {:error, :invalid_span} + end + + # Clean up: remove the transaction root span and all its children + :ok = SpanStorage.remove_root_span(span_record.span_id) + + result end @impl :otel_span_processor @@ -60,6 +99,24 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do :ok end + # Checks if a parent span exists in our local SpanStorage + # This helps distinguish between: + # - Local parents: span exists in storage (same service) + # - Remote parents: span doesn't exist in storage (distributed tracing from another service) + defp has_local_parent_span?(parent_span_id) do + SpanStorage.span_exists?(parent_span_id) + end + + # Helper function to detect if a span is a server span that should be + # treated as a transaction root for distributed tracing. + # This includes HTTP server request spans (have http.request.method attribute) + defp is_server_span?(%{kind: :server, attributes: attributes}) do + # Check if it's an HTTP server request span (has http.request.method) + Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method())) + end + + defp is_server_span?(_), do: false + defp build_transaction(root_span_record, child_span_records) do root_span = build_span(root_span_record) child_spans = Enum.map(child_span_records, &build_span(&1)) @@ -112,12 +169,27 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do client_address = Map.get(span_record.attributes, to_string(ClientAttributes.client_address())) - url_path = Map.get(span_record.attributes, to_string(URLAttributes.url_path())) + # Try multiple attributes for the URL path + url_path = + Map.get(span_record.attributes, to_string(URLAttributes.url_path())) || + Map.get(span_record.attributes, "url.full") || + Map.get(span_record.attributes, "http.target") || + Map.get(span_record.attributes, "http.route") || + span_record.name + # Build description with method and path description = - to_string(http_request_method) <> - ((client_address && " from #{client_address}") || "") <> - ((url_path && " #{url_path}") || "") + case url_path do + nil -> to_string(http_request_method) + path -> "#{http_request_method} #{path}" + end + + description = + if client_address do + "#{description} from #{client_address}" + else + description + end {op, description} end diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index 4b69b936..0f7d6a1e 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -37,6 +37,22 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do {:noreply, state} end + @spec span_exists?(String.t(), keyword()) :: boolean() + def span_exists?(span_id, opts \\ []) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + + case :ets.lookup(table_name, {:root_span, span_id}) do + [{{:root_span, ^span_id}, _span, _stored_at}] -> + true + + [] -> + case :ets.match_object(table_name, {{:child_span, :_, span_id}, :_, :_}) do + [] -> false + _ -> true + end + end + end + @spec store_span(SpanRecord.t(), keyword()) :: true def store_span(span_data, opts \\ []) do table_name = Keyword.get(opts, :table_name, default_table_name()) diff --git a/test/sentry/logger_handler_test.exs b/test/sentry/logger_handler_test.exs index fa10058d..355a2cdd 100644 --- a/test/sentry/logger_handler_test.exs +++ b/test/sentry/logger_handler_test.exs @@ -671,8 +671,7 @@ defmodule Sentry.LoggerHandlerTest do %ArgumentError{ message: ":sync_threshold and :discard_threshold cannot be used together, one of them must be nil" - }, - _}}} = + }, _}}} = :logger.update_handler_config( @handler_name, :config, diff --git a/test/sentry/opentelemetry/propagator_test.exs b/test/sentry/opentelemetry/propagator_test.exs new file mode 100644 index 00000000..a1b38114 --- /dev/null +++ b/test/sentry/opentelemetry/propagator_test.exs @@ -0,0 +1,284 @@ +defmodule Sentry.OpenTelemetry.PropagatorTest do + use ExUnit.Case, async: true + + alias Sentry.OpenTelemetry.Propagator + + @moduletag skip: not Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() + + if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + require OpenTelemetry.Tracer, as: Tracer + require Record + + @fields Record.extract(:span_ctx, from_lib: "opentelemetry_api/include/opentelemetry.hrl") + Record.defrecordp(:span_ctx, @fields) + + describe "fields/1" do + test "returns the header fields used by the propagator" do + assert Propagator.fields([]) == ["sentry-trace", "baggage"] + end + end + + describe "inject/4" do + test "injects sentry-trace header from current span context" do + trace_id = 0x1234567890ABCDEF1234567890ABCDEF + span_id = 0x1234567890ABCDEF + trace_flags = 1 + + span_context = + span_ctx( + trace_id: trace_id, + span_id: span_id, + trace_flags: trace_flags, + tracestate: [], + is_valid: true, + is_remote: false + ) + + ctx = Tracer.set_current_span(:otel_ctx.new(), span_context) + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + assert Map.has_key?(carrier, "sentry-trace") + sentry_trace = Map.get(carrier, "sentry-trace") + + # Verify format: {trace_id}-{span_id}-{sampled} + assert sentry_trace =~ ~r/^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/ + assert String.ends_with?(sentry_trace, "-1") + end + + test "does not inject when no span context is present" do + ctx = :otel_ctx.new() + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + assert carrier == %{} + end + end + + describe "extract/5" do + test "extracts sentry-trace header and sets remote span context" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef-1" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + "baggage" -> :undefined + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify that a remote span context was set + span_ctx = Tracer.current_span_ctx(ctx) + assert span_ctx != :undefined + + # Verify trace and span IDs were converted correctly + expected_trace_id = 0x1234567890ABCDEF1234567890ABCDEF + expected_span_id = 0x1234567890ABCDEF + + assert span_ctx(span_ctx, :trace_id) == expected_trace_id + assert span_ctx(span_ctx, :span_id) == expected_span_id + assert span_ctx(span_ctx, :trace_flags) == 1 + assert span_ctx(span_ctx, :is_remote) == true + end + + test "extracts sentry-trace without sampled flag" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify remote span context was set + span_ctx = Tracer.current_span_ctx(ctx) + assert span_ctx != :undefined + + # Even when not sampled in the header, trace_flags should be 1 + # because we always simulate sampled traces on the OTel side + assert span_ctx(span_ctx, :trace_flags) == 1 + assert span_ctx(span_ctx, :is_remote) == true + end + + test "handles missing sentry-trace header" do + getter = fn _key, _carrier -> :undefined end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Context should be unchanged + assert Tracer.current_span_ctx(ctx) == :undefined + end + + test "handles invalid sentry-trace header format" do + invalid_headers = [ + "invalid", + "1234-5678", + "toolong1234567890abcdef1234567890abcdef-1234567890abcdef-1" + ] + + for invalid_header <- invalid_headers do + getter = fn key, _carrier -> + case key do + "sentry-trace" -> invalid_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Invalid headers should be ignored - no remote span should be set + assert Tracer.current_span_ctx(ctx) == :undefined + end + end + + test "extracts and stores baggage header" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef-1" + + baggage_header = + "sentry-trace_id=771a43a4192642f0b136d5159a501700," <> + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," <> + "sentry-sample_rate=0.01337,sentry-user_id=Am%C3%A9lie" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + "baggage" -> baggage_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify baggage was stored in context + stored_baggage = :otel_ctx.get_value(ctx, :"sentry-baggage", :not_found) + assert stored_baggage == baggage_header + end + + test "handles missing baggage header" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef-1" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify baggage is not set when not provided + stored_baggage = :otel_ctx.get_value(ctx, :"sentry-baggage", :not_found) + assert stored_baggage == :not_found + end + end + + describe "baggage propagation" do + test "injects baggage from context" do + trace_id = 0x1234567890ABCDEF1234567890ABCDEF + span_id = 0x1234567890ABCDEF + trace_flags = 1 + baggage_value = "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-release=1.0.0" + + span_context = + span_ctx( + trace_id: trace_id, + span_id: span_id, + trace_flags: trace_flags, + tracestate: [], + is_valid: true, + is_remote: false + ) + + ctx = + :otel_ctx.new() + |> Tracer.set_current_span(span_context) + |> :otel_ctx.set_value(:"sentry-baggage", baggage_value) + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + # Verify both sentry-trace and baggage are injected + assert Map.has_key?(carrier, "sentry-trace") + assert Map.get(carrier, "baggage") == baggage_value + end + + test "does not inject baggage when not in context" do + trace_id = 0x1234567890ABCDEF1234567890ABCDEF + span_id = 0x1234567890ABCDEF + trace_flags = 1 + + span_context = + span_ctx( + trace_id: trace_id, + span_id: span_id, + trace_flags: trace_flags, + tracestate: [], + is_valid: true, + is_remote: false + ) + + ctx = Tracer.set_current_span(:otel_ctx.new(), span_context) + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + # Verify only sentry-trace is injected + assert Map.has_key?(carrier, "sentry-trace") + assert not Map.has_key?(carrier, "baggage") + end + end + + describe "integration with OpenTelemetry" do + test "round-trip inject and extract preserves trace context" do + # Start a span to create a trace context + Tracer.with_span "test_span" do + ctx = :otel_ctx.get_current() + span_ctx = Tracer.current_span_ctx(ctx) + + original_trace_id = span_ctx(span_ctx, :trace_id) + original_span_id = span_ctx(span_ctx, :span_id) + + # Inject into carrier + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + # Extract from carrier + getter = fn key, carrier -> + Map.get(carrier, key, :undefined) + end + + new_ctx = Propagator.extract(:otel_ctx.new(), carrier, nil, getter, []) + new_span_ctx = Tracer.current_span_ctx(new_ctx) + + # Trace ID should be preserved + assert span_ctx(new_span_ctx, :trace_id) == original_trace_id + + # The span ID in the new context becomes the parent span ID + assert span_ctx(new_span_ctx, :span_id) == original_span_id + end + end + end + end +end diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index eaa76f39..abd4ca8d 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -1,6 +1,10 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do use Sentry.Case, async: false + require OpenTelemetry.Tracer, as: Tracer + require OpenTelemetry.SemConv.Incubating.HTTPAttributes, as: HTTPAttributes + require OpenTelemetry.SemConv.Incubating.URLAttributes, as: URLAttributes + import Sentry.TestHelpers alias Sentry.OpenTelemetry.SpanStorage @@ -188,8 +192,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Sentry.Test.start_collecting_sentry_reports() - require OpenTelemetry.Tracer, as: Tracer - Tracer.with_span "root_span" do Tracer.with_span "level_1_child" do Tracer.with_span "level_2_child" do @@ -247,8 +249,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do tasks = Enum.map(1..20, fn i -> Task.async(fn -> - require OpenTelemetry.Tracer, as: Tracer - Tracer.with_span "concurrent_root_#{i}" do Tracer.with_span "concurrent_child_#{i}" do Process.sleep(10) @@ -286,8 +286,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Sentry.Test.start_collecting_sentry_reports() - require OpenTelemetry.Tracer, as: Tracer - Tracer.with_span "root_span" do Tracer.with_span "child_instrumented_function_one" do Process.sleep(10) @@ -310,5 +308,145 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Application.put_env(:opentelemetry, :sampler, original_sampler) end + + @tag span_storage: true + test "treats HTTP server request spans as transaction roots for distributed tracing" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + # Simulate an incoming HTTP request with an external parent span ID (from browser/client) + # This represents a distributed trace where the client started the trace + external_trace_id = 0x1234567890ABCDEF1234567890ABCDEF + external_parent_span_id = 0xABCDEF1234567890 + + # Create a remote parent span context using :otel_tracer.from_remote_span + remote_parent = :otel_tracer.from_remote_span(external_trace_id, external_parent_span_id, 1) + + ctx = Tracer.set_current_span(:otel_ctx.new(), remote_parent) + + # Start an HTTP server span with the remote parent context + Tracer.with_span ctx, "POST /api/users", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :POST, + URLAttributes.url_path() => "/api/users", + "http.route" => "/api/users", + "server.address" => "localhost", + "server.port" => 4000 + } + } do + # Simulate child spans (database queries, etc.) + Tracer.with_span "db.query:users", %{ + kind: :client, + attributes: %{ + "db.system" => :postgresql, + "db.statement" => "INSERT INTO users (name) VALUES ($1)" + } + } do + Process.sleep(10) + end + + Tracer.with_span "db.query:notifications", %{ + kind: :client, + attributes: %{ + "db.system" => :postgresql, + "db.statement" => "INSERT INTO notifications (user_id) VALUES ($1)" + } + } do + Process.sleep(10) + end + end + + # Should capture the HTTP request span as a transaction root despite having an external parent + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + # Verify transaction properties + assert transaction.transaction == "POST /api/users" + assert transaction.transaction_info == %{source: :custom} + assert length(transaction.spans) == 2 + + # Verify child spans are properly included + span_ops = Enum.map(transaction.spans, & &1.op) |> Enum.sort() + assert span_ops == ["db", "db"] + + # Verify child spans have detailed data (like SQL queries) + [span1, span2] = transaction.spans + assert span1.description =~ "INSERT INTO" + assert span2.description =~ "INSERT INTO" + assert span1.data["db.system"] == :postgresql + assert span2.data["db.system"] == :postgresql + assert span1.data["db.statement"] =~ "INSERT INTO users" + assert span2.data["db.statement"] =~ "INSERT INTO notifications" + + # Verify all spans share the same trace ID (from the external parent) + trace_id = transaction.contexts.trace.trace_id + + Enum.each(transaction.spans, fn span -> + assert span.trace_id == trace_id + end) + + # The transaction should have the external parent's trace ID + assert transaction.contexts.trace.trace_id == + "1234567890abcdef1234567890abcdef" + end + + @tag span_storage: true + test "cleans up HTTP server span and children after sending distributed trace transaction", %{ + table_name: table_name + } do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + # Simulate an incoming HTTP request with an external parent span ID (from browser/client) + external_trace_id = 0x1234567890ABCDEF1234567890ABCDEF + external_parent_span_id = 0xABCDEF1234567890 + + remote_parent = :otel_tracer.from_remote_span(external_trace_id, external_parent_span_id, 1) + ctx = Tracer.set_current_span(:otel_ctx.new(), remote_parent) + + # Start an HTTP server span with the remote parent context + Tracer.with_span ctx, "POST /api/users", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :POST, + URLAttributes.url_path() => "/api/users" + } + } do + # Simulate child spans (database queries, etc.) + Tracer.with_span "db.query:users", %{ + kind: :client, + attributes: %{ + "db.system" => :postgresql, + "db.statement" => "INSERT INTO users (name) VALUES ($1)" + } + } do + Process.sleep(10) + end + end + + # Should capture the HTTP request span as a transaction + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + # Verify the HTTP server span was removed from storage + # (even though it was stored as a child span due to having a remote parent) + http_server_span_id = transaction.contexts.trace.span_id + remote_parent_span_id_str = "abcdef1234567890" + + # The HTTP server span should not exist in storage anymore + assert SpanStorage.get_root_span(http_server_span_id, table_name: table_name) == nil + + # Check that it was also removed from child spans storage + # We can't directly check if a specific child was removed, but we can verify + # that get_child_spans for the remote parent returns empty (or doesn't include our span) + remaining_children = + SpanStorage.get_child_spans(remote_parent_span_id_str, table_name: table_name) + + refute Enum.any?(remaining_children, fn span -> span.span_id == http_server_span_id end) + + # Verify child spans of the HTTP server span were also removed + assert [] == SpanStorage.get_child_spans(http_server_span_id, table_name: table_name) + end end end