Skip to content

Commit 23aad88

Browse files
committed
wip - update SpanProcessor for DT
1 parent 1b6248b commit 23aad88

File tree

2 files changed

+686
-29
lines changed

2 files changed

+686
-29
lines changed

lib/sentry/opentelemetry/span_processor.ex

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

0 commit comments

Comments
 (0)