@@ -11,55 +11,173 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
1111 require OpenTelemetry.SemConv.Incubating.MessagingAttributes , as: MessagingAttributes
1212
1313 require Logger
14+ require Record
1415
1516 alias Sentry . { Transaction , OpenTelemetry.SpanStorage , OpenTelemetry.SpanRecord }
1617 alias Sentry.Interfaces.Span
1718
18- # This can be a no-op since we can postpone inserting the span into storage until on_end
19+ # Extract span record fields to access parent_span_id in on_start
20+ @ span_fields Record . extract ( :span , from_lib: "opentelemetry/include/otel_span.hrl" )
21+ Record . defrecordp ( :span , @ span_fields )
22+
1923 @ impl :otel_span_processor
2024 def on_start ( _ctx , otel_span , _config ) do
25+ # Track pending children: when a span starts with a parent, register it
26+ # as a pending child. This allows us to wait for all children when
27+ # the parent ends, solving the race condition where parent.on_end
28+ # is called before child.on_end.
29+ parent_span_id = span ( otel_span , :parent_span_id )
30+ span_id = span ( otel_span , :span_id )
31+
32+ if parent_span_id != nil and parent_span_id != :undefined do
33+ parent_span_id_str = cast_span_id ( parent_span_id )
34+ span_id_str = cast_span_id ( span_id )
35+
36+ if parent_span_id_str != nil and span_id_str != nil do
37+ SpanStorage . store_pending_child ( parent_span_id_str , span_id_str )
38+ end
39+ end
40+
2141 otel_span
2242 end
2343
2444 @ impl :otel_span_processor
2545 def on_end ( otel_span , _config ) do
2646 span_record = SpanRecord . new ( otel_span )
47+ process_span ( span_record )
48+ end
2749
50+ defp process_span ( span_record ) do
2851 SpanStorage . store_span ( span_record )
2952
30- if span_record . parent_span_id == nil do
31- child_span_records = SpanStorage . get_child_spans ( span_record . span_id )
32- transaction = build_transaction ( span_record , child_span_records )
53+ # Remove this span from pending children of its parent (if any)
54+ # This must happen after store_span so the span is available when
55+ # the parent builds its transaction
56+ if span_record . parent_span_id != nil do
57+ SpanStorage . remove_pending_child ( span_record . parent_span_id , span_record . span_id )
3358
34- result =
35- case Sentry . send_transaction ( transaction ) do
36- { :ok , _id } ->
37- true
59+ # Check if our parent was waiting for us (and possibly other siblings)
60+ # If parent has ended and we were the last pending child, build the transaction now
61+ maybe_complete_waiting_parent ( span_record . parent_span_id )
62+ end
3863
39- :ignored ->
40- true
64+ # Check if this is a root span (no parent) or a transaction root
65+ #
66+ # A span should be a transaction root if:
67+ # 1. It has no parent (true root span)
68+ # 2. OR it's a server span with only a REMOTE parent (distributed tracing)
69+ #
70+ # A span should NOT be a transaction root if:
71+ # - It has a LOCAL parent (parent span exists in our SpanStorage)
72+ is_transaction_root =
73+ cond do
74+ # No parent = definitely a root
75+ span_record . parent_span_id == nil ->
76+ true
77+
78+ # Has a parent - check if it's local or remote
79+ true ->
80+ has_local_parent = has_local_parent_span? ( span_record . parent_span_id )
81+
82+ if has_local_parent do
83+ # Parent exists locally - this is a child span, not a transaction root
84+ false
85+ else
86+ # Parent is remote (distributed tracing) - treat server spans as transaction roots
87+ is_server_span? ( span_record )
88+ end
89+ end
4190
42- :excluded ->
43- true
91+ if is_transaction_root do
92+ # Check if we have pending children that haven't ended yet
93+ if SpanStorage . has_pending_children? ( span_record . span_id ) do
94+ # Store as waiting parent - transaction will be built when last child ends
95+ SpanStorage . store_waiting_parent ( span_record )
96+ true
97+ else
98+ # No pending children, build and send transaction immediately
99+ build_and_send_transaction ( span_record )
100+ end
101+ else
102+ true
103+ end
104+ end
44105
45- { :error , error } ->
46- Logger . warning ( "Failed to send transaction to Sentry: #{ inspect ( error ) } " )
47- { :error , :invalid_span }
106+ # Called when a child span ends to check if its parent was waiting for children
107+ defp maybe_complete_waiting_parent ( parent_span_id ) do
108+ case SpanStorage . get_waiting_parent ( parent_span_id ) do
109+ nil ->
110+ # Parent hasn't ended yet or wasn't a transaction root - nothing to do
111+ :ok
112+
113+ parent_span_record ->
114+ # Parent was waiting. Check if all children are done now.
115+ unless SpanStorage . has_pending_children? ( parent_span_id ) do
116+ # All children done! Build and send the transaction.
117+ SpanStorage . remove_waiting_parent ( parent_span_id )
118+ build_and_send_transaction ( parent_span_record )
48119 end
120+ end
121+ end
49122
50- :ok = SpanStorage . remove_root_span ( span_record . span_id )
123+ defp build_and_send_transaction ( span_record ) do
124+ child_span_records = SpanStorage . get_child_spans ( span_record . span_id )
125+ transaction = build_transaction ( span_record , child_span_records )
51126
52- result
53- else
54- true
127+ result =
128+ case Sentry . send_transaction ( transaction ) do
129+ { :ok , _id } ->
130+ true
131+
132+ :ignored ->
133+ true
134+
135+ :excluded ->
136+ true
137+
138+ { :error , error } ->
139+ Logger . warning ( "Failed to send transaction to Sentry: #{ inspect ( error ) } " )
140+ { :error , :invalid_span }
141+ end
142+
143+ # Clean up: remove the transaction root span and all its children
144+ :ok = SpanStorage . remove_root_span ( span_record . span_id )
145+
146+ # Also clean up any remaining pending children records (shouldn't be any, but be safe)
147+ :ok = SpanStorage . remove_pending_children ( span_record . span_id )
148+
149+ if span_record . parent_span_id != nil do
150+ # This span was stored as a child because it has a remote parent (distributed tracing).
151+ # We need to explicitly remove it from the child spans storage.
152+ :ok = SpanStorage . remove_child_span ( span_record . parent_span_id , span_record . span_id )
55153 end
154+
155+ result
56156 end
57157
58158 @ impl :otel_span_processor
59159 def force_flush ( _config ) do
60160 :ok
61161 end
62162
163+ # Checks if a parent span exists in our local SpanStorage
164+ # This helps distinguish between:
165+ # - Local parents: span exists in storage (same service)
166+ # - Remote parents: span doesn't exist in storage (distributed tracing from another service)
167+ defp has_local_parent_span? ( parent_span_id ) do
168+ SpanStorage . span_exists? ( parent_span_id )
169+ end
170+
171+ # Helper function to detect if a span is a server span that should be
172+ # treated as a transaction root for distributed tracing.
173+ # This includes HTTP server request spans (have http.request.method attribute)
174+ defp is_server_span? ( % { kind: :server , attributes: attributes } ) do
175+ # Check if it's an HTTP server request span (has http.request.method)
176+ Map . has_key? ( attributes , to_string ( HTTPAttributes . http_request_method ( ) ) )
177+ end
178+
179+ defp is_server_span? ( _ ) , do: false
180+
63181 defp build_transaction ( root_span_record , child_span_records ) do
64182 root_span = build_span ( root_span_record )
65183 child_spans = Enum . map ( child_span_records , & build_span ( & 1 ) )
@@ -112,12 +230,27 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
112230 client_address =
113231 Map . get ( span_record . attributes , to_string ( ClientAttributes . client_address ( ) ) )
114232
115- url_path = Map . get ( span_record . attributes , to_string ( URLAttributes . url_path ( ) ) )
233+ # Try multiple attributes for the URL path
234+ url_path =
235+ Map . get ( span_record . attributes , to_string ( URLAttributes . url_path ( ) ) ) ||
236+ Map . get ( span_record . attributes , "url.full" ) ||
237+ Map . get ( span_record . attributes , "http.target" ) ||
238+ Map . get ( span_record . attributes , "http.route" ) ||
239+ span_record . name
116240
241+ # Build description with method and path
117242 description =
118- to_string ( http_request_method ) <>
119- ( ( client_address && " from #{ client_address } " ) || "" ) <>
120- ( ( url_path && " #{ url_path } " ) || "" )
243+ case url_path do
244+ nil -> to_string ( http_request_method )
245+ path -> "#{ http_request_method } #{ path } "
246+ end
247+
248+ description =
249+ if client_address do
250+ "#{ description } from #{ client_address } "
251+ else
252+ description
253+ end
121254
122255 { op , description }
123256 end
@@ -207,5 +340,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
207340 end )
208341 |> Map . new ( )
209342 end
343+
344+ # Helper to convert span ID from integer to hex string (used in on_start)
345+ defp cast_span_id ( nil ) , do: nil
346+ defp cast_span_id ( :undefined ) , do: nil
347+
348+ defp cast_span_id ( span_id ) do
349+ case :otel_utils . format_binary_string ( "~16.16.0b" , [ span_id ] ) do
350+ { :ok , result } -> result
351+ { :error , _ } -> nil
352+ end
353+ end
210354 end
211355end
0 commit comments