@@ -11,55 +11,174 @@ 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+ _ =
57+ if span_record . parent_span_id != nil do
58+ SpanStorage . remove_pending_child ( span_record . parent_span_id , span_record . span_id )
3359
34- result =
35- case Sentry . send_transaction ( transaction ) do
36- { :ok , _id } ->
37- true
60+ # Check if our parent was waiting for us (and possibly other siblings)
61+ # If parent has ended and we were the last pending child, build the transaction now
62+ maybe_complete_waiting_parent ( span_record . parent_span_id )
63+ end
3864
39- :ignored ->
40- true
65+ # Check if this is a root span (no parent) or a transaction root
66+ #
67+ # A span should be a transaction root if:
68+ # 1. It has no parent (true root span)
69+ # 2. OR it's a server span with only a REMOTE parent (distributed tracing)
70+ #
71+ # A span should NOT be a transaction root if:
72+ # - It has a LOCAL parent (parent span exists in our SpanStorage)
73+ is_transaction_root =
74+ cond do
75+ # No parent = definitely a root
76+ span_record . parent_span_id == nil ->
77+ true
78+
79+ # Has a parent - check if it's local or remote
80+ true ->
81+ has_local_parent = has_local_parent_span? ( span_record . parent_span_id )
82+
83+ if has_local_parent do
84+ # Parent exists locally - this is a child span, not a transaction root
85+ false
86+ else
87+ # Parent is remote (distributed tracing) - treat server spans as transaction roots
88+ is_server_span? ( span_record )
89+ end
90+ end
4191
42- :excluded ->
43- true
92+ if is_transaction_root do
93+ # Check if we have pending children that haven't ended yet
94+ if SpanStorage . has_pending_children? ( span_record . span_id ) do
95+ # Store as waiting parent - transaction will be built when last child ends
96+ SpanStorage . store_waiting_parent ( span_record )
97+ true
98+ else
99+ # No pending children, build and send transaction immediately
100+ build_and_send_transaction ( span_record )
101+ end
102+ else
103+ true
104+ end
105+ end
44106
45- { :error , error } ->
46- Logger . warning ( "Failed to send transaction to Sentry: #{ inspect ( error ) } " )
47- { :error , :invalid_span }
107+ # Called when a child span ends to check if its parent was waiting for children
108+ defp maybe_complete_waiting_parent ( parent_span_id ) do
109+ case SpanStorage . get_waiting_parent ( parent_span_id ) do
110+ nil ->
111+ # Parent hasn't ended yet or wasn't a transaction root - nothing to do
112+ :ok
113+
114+ parent_span_record ->
115+ # Parent was waiting. Check if all children are done now.
116+ unless SpanStorage . has_pending_children? ( parent_span_id ) do
117+ # All children done! Build and send the transaction.
118+ SpanStorage . remove_waiting_parent ( parent_span_id )
119+ build_and_send_transaction ( parent_span_record )
48120 end
121+ end
122+ end
49123
50- :ok = SpanStorage . remove_root_span ( span_record . span_id )
124+ defp build_and_send_transaction ( span_record ) do
125+ child_span_records = SpanStorage . get_child_spans ( span_record . span_id )
126+ transaction = build_transaction ( span_record , child_span_records )
51127
52- result
53- else
54- true
128+ result =
129+ case Sentry . send_transaction ( transaction ) do
130+ { :ok , _id } ->
131+ true
132+
133+ :ignored ->
134+ true
135+
136+ :excluded ->
137+ true
138+
139+ { :error , error } ->
140+ Logger . warning ( "Failed to send transaction to Sentry: #{ inspect ( error ) } " )
141+ { :error , :invalid_span }
142+ end
143+
144+ # Clean up: remove the transaction root span and all its children
145+ :ok = SpanStorage . remove_root_span ( span_record . span_id )
146+
147+ # Also clean up any remaining pending children records (shouldn't be any, but be safe)
148+ :ok = SpanStorage . remove_pending_children ( span_record . span_id )
149+
150+ if span_record . parent_span_id != nil do
151+ # This span was stored as a child because it has a remote parent (distributed tracing).
152+ # We need to explicitly remove it from the child spans storage.
153+ :ok = SpanStorage . remove_child_span ( span_record . parent_span_id , span_record . span_id )
55154 end
155+
156+ result
56157 end
57158
58159 @ impl :otel_span_processor
59160 def force_flush ( _config ) do
60161 :ok
61162 end
62163
164+ # Checks if a parent span exists in our local SpanStorage
165+ # This helps distinguish between:
166+ # - Local parents: span exists in storage (same service)
167+ # - Remote parents: span doesn't exist in storage (distributed tracing from another service)
168+ defp has_local_parent_span? ( parent_span_id ) do
169+ SpanStorage . span_exists? ( parent_span_id )
170+ end
171+
172+ # Helper function to detect if a span is a server span that should be
173+ # treated as a transaction root for distributed tracing.
174+ # This includes HTTP server request spans (have http.request.method attribute)
175+ defp is_server_span? ( % { kind: :server , attributes: attributes } ) do
176+ # Check if it's an HTTP server request span (has http.request.method)
177+ Map . has_key? ( attributes , to_string ( HTTPAttributes . http_request_method ( ) ) )
178+ end
179+
180+ defp is_server_span? ( _ ) , do: false
181+
63182 defp build_transaction ( root_span_record , child_span_records ) do
64183 root_span = build_span ( root_span_record )
65184 child_spans = Enum . map ( child_span_records , & build_span ( & 1 ) )
@@ -112,12 +231,27 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
112231 client_address =
113232 Map . get ( span_record . attributes , to_string ( ClientAttributes . client_address ( ) ) )
114233
115- url_path = Map . get ( span_record . attributes , to_string ( URLAttributes . url_path ( ) ) )
234+ # Try multiple attributes for the URL path
235+ url_path =
236+ Map . get ( span_record . attributes , to_string ( URLAttributes . url_path ( ) ) ) ||
237+ Map . get ( span_record . attributes , "url.full" ) ||
238+ Map . get ( span_record . attributes , "http.target" ) ||
239+ Map . get ( span_record . attributes , "http.route" ) ||
240+ span_record . name
116241
242+ # Build description with method and path
117243 description =
118- to_string ( http_request_method ) <>
119- ( ( client_address && " from #{ client_address } " ) || "" ) <>
120- ( ( url_path && " #{ url_path } " ) || "" )
244+ case url_path do
245+ nil -> to_string ( http_request_method )
246+ path -> "#{ http_request_method } #{ path } "
247+ end
248+
249+ description =
250+ if client_address do
251+ "#{ description } from #{ client_address } "
252+ else
253+ description
254+ end
121255
122256 { op , description }
123257 end
@@ -207,5 +341,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
207341 end )
208342 |> Map . new ( )
209343 end
344+
345+ # Helper to convert span ID from integer to hex string (used in on_start)
346+ defp cast_span_id ( nil ) , do: nil
347+ defp cast_span_id ( :undefined ) , do: nil
348+
349+ defp cast_span_id ( span_id ) do
350+ case :otel_utils . format_binary_string ( "~16.16.0b" , [ span_id ] ) do
351+ { :ok , result } -> result
352+ { :error , _ } -> nil
353+ end
354+ end
210355 end
211356end
0 commit comments