Skip to content

Commit fa5bf68

Browse files
committed
wip - update SpanProcessor for DT
1 parent e09d281 commit fa5bf68

File tree

2 files changed

+685
-29
lines changed

2 files changed

+685
-29
lines changed

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 167 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
211355
end

0 commit comments

Comments
 (0)