Skip to content

Commit bbdc484

Browse files
committed
wip - update span processor
1 parent cea1722 commit bbdc484

File tree

3 files changed

+178
-7
lines changed

3 files changed

+178
-7
lines changed

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
2727

2828
SpanStorage.store_span(span_record)
2929

30-
if span_record.parent_span_id == nil do
30+
# Check if this is a root span (no parent) or a transaction root (HTTP server request span)
31+
# HTTP server request spans should be treated as transaction roots even when they have
32+
# an external parent span ID (from distributed tracing)
33+
is_transaction_root =
34+
span_record.parent_span_id == nil or is_http_server_request_span?(span_record)
35+
36+
if is_transaction_root do
3137
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
3238
transaction = build_transaction(span_record, child_span_records)
3339

@@ -47,8 +53,18 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
4753
{:error, :invalid_span}
4854
end
4955

56+
# Clean up: remove the transaction root span and all its children
57+
# Note: For distributed tracing, the transaction root span may have been stored
58+
# as a child span (with a remote parent_span_id). In that case, we need to also
59+
# remove it from the child spans, not just look for it as a root span.
5060
:ok = SpanStorage.remove_root_span(span_record.span_id)
5161

62+
if span_record.parent_span_id != nil do
63+
# This span was stored as a child because it has a remote parent (distributed tracing).
64+
# We need to explicitly remove it from the child spans storage.
65+
:ok = SpanStorage.remove_child_span(span_record.parent_span_id, span_record.span_id)
66+
end
67+
5268
result
5369
else
5470
true
@@ -60,6 +76,13 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
6076
:ok
6177
end
6278

79+
# Helper function to detect if a span represents an HTTP server request
80+
# that should be treated as a transaction root for distributed tracing
81+
defp is_http_server_request_span?(%{kind: kind, attributes: attributes}) do
82+
kind == :server and
83+
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
84+
end
85+
6386
defp build_transaction(root_span_record, child_span_records) do
6487
root_span = build_span(root_span_record)
6588
child_spans = Enum.map(child_span_records, &build_span(&1))

lib/sentry/opentelemetry/span_storage.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
121121
:ok
122122
end
123123

124+
@spec remove_child_span(String.t(), String.t(), keyword()) :: :ok
125+
def remove_child_span(parent_span_id, span_id, opts \\ []) do
126+
table_name = Keyword.get(opts, :table_name, default_table_name())
127+
key = {:child_span, parent_span_id, span_id}
128+
129+
:ets.delete(table_name, key)
130+
131+
:ok
132+
end
133+
124134
defp schedule_cleanup(interval) do
125135
Process.send_after(self(), :cleanup_stale_spans, interval)
126136
end

test/sentry/opentelemetry/span_processor_test.exs

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
defmodule Sentry.Opentelemetry.SpanProcessorTest do
22
use Sentry.Case, async: false
33

4+
require OpenTelemetry.Tracer, as: Tracer
5+
require OpenTelemetry.SemConv.Incubating.HTTPAttributes, as: HTTPAttributes
6+
require OpenTelemetry.SemConv.Incubating.URLAttributes, as: URLAttributes
7+
48
import Sentry.TestHelpers
59

610
alias Sentry.OpenTelemetry.SpanStorage
@@ -188,8 +192,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do
188192

189193
Sentry.Test.start_collecting_sentry_reports()
190194

191-
require OpenTelemetry.Tracer, as: Tracer
192-
193195
Tracer.with_span "root_span" do
194196
Tracer.with_span "level_1_child" do
195197
Tracer.with_span "level_2_child" do
@@ -247,8 +249,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do
247249
tasks =
248250
Enum.map(1..20, fn i ->
249251
Task.async(fn ->
250-
require OpenTelemetry.Tracer, as: Tracer
251-
252252
Tracer.with_span "concurrent_root_#{i}" do
253253
Tracer.with_span "concurrent_child_#{i}" do
254254
Process.sleep(10)
@@ -286,8 +286,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do
286286

287287
Sentry.Test.start_collecting_sentry_reports()
288288

289-
require OpenTelemetry.Tracer, as: Tracer
290-
291289
Tracer.with_span "root_span" do
292290
Tracer.with_span "child_instrumented_function_one" do
293291
Process.sleep(10)
@@ -310,5 +308,145 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do
310308

311309
Application.put_env(:opentelemetry, :sampler, original_sampler)
312310
end
311+
312+
@tag span_storage: true
313+
test "treats HTTP server request spans as transaction roots for distributed tracing" do
314+
put_test_config(environment_name: "test", traces_sample_rate: 1.0)
315+
316+
Sentry.Test.start_collecting_sentry_reports()
317+
318+
# Simulate an incoming HTTP request with an external parent span ID (from browser/client)
319+
# This represents a distributed trace where the client started the trace
320+
external_trace_id = 0x1234567890ABCDEF1234567890ABCDEF
321+
external_parent_span_id = 0xABCDEF1234567890
322+
323+
# Create a remote parent span context using :otel_tracer.from_remote_span
324+
remote_parent = :otel_tracer.from_remote_span(external_trace_id, external_parent_span_id, 1)
325+
326+
ctx = Tracer.set_current_span(:otel_ctx.new(), remote_parent)
327+
328+
# Start an HTTP server span with the remote parent context
329+
Tracer.with_span ctx, "POST /api/users", %{
330+
kind: :server,
331+
attributes: %{
332+
HTTPAttributes.http_request_method() => :POST,
333+
URLAttributes.url_path() => "/api/users",
334+
"http.route" => "/api/users",
335+
"server.address" => "localhost",
336+
"server.port" => 4000
337+
}
338+
} do
339+
# Simulate child spans (database queries, etc.)
340+
Tracer.with_span "db.query:users", %{
341+
kind: :client,
342+
attributes: %{
343+
"db.system" => :postgresql,
344+
"db.statement" => "INSERT INTO users (name) VALUES ($1)"
345+
}
346+
} do
347+
Process.sleep(10)
348+
end
349+
350+
Tracer.with_span "db.query:notifications", %{
351+
kind: :client,
352+
attributes: %{
353+
"db.system" => :postgresql,
354+
"db.statement" => "INSERT INTO notifications (user_id) VALUES ($1)"
355+
}
356+
} do
357+
Process.sleep(10)
358+
end
359+
end
360+
361+
# Should capture the HTTP request span as a transaction root despite having an external parent
362+
assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions()
363+
364+
# Verify transaction properties
365+
assert transaction.transaction == "POST /api/users"
366+
assert transaction.transaction_info == %{source: :custom}
367+
assert length(transaction.spans) == 2
368+
369+
# Verify child spans are properly included
370+
span_ops = Enum.map(transaction.spans, & &1.op) |> Enum.sort()
371+
assert span_ops == ["db", "db"]
372+
373+
# Verify child spans have detailed data (like SQL queries)
374+
[span1, span2] = transaction.spans
375+
assert span1.description =~ "INSERT INTO"
376+
assert span2.description =~ "INSERT INTO"
377+
assert span1.data["db.system"] == :postgresql
378+
assert span2.data["db.system"] == :postgresql
379+
assert span1.data["db.statement"] =~ "INSERT INTO users"
380+
assert span2.data["db.statement"] =~ "INSERT INTO notifications"
381+
382+
# Verify all spans share the same trace ID (from the external parent)
383+
trace_id = transaction.contexts.trace.trace_id
384+
385+
Enum.each(transaction.spans, fn span ->
386+
assert span.trace_id == trace_id
387+
end)
388+
389+
# The transaction should have the external parent's trace ID
390+
assert transaction.contexts.trace.trace_id ==
391+
"1234567890abcdef1234567890abcdef"
392+
end
393+
394+
@tag span_storage: true
395+
test "cleans up HTTP server span and children after sending distributed trace transaction", %{
396+
table_name: table_name
397+
} do
398+
put_test_config(environment_name: "test", traces_sample_rate: 1.0)
399+
400+
Sentry.Test.start_collecting_sentry_reports()
401+
402+
# Simulate an incoming HTTP request with an external parent span ID (from browser/client)
403+
external_trace_id = 0x1234567890ABCDEF1234567890ABCDEF
404+
external_parent_span_id = 0xABCDEF1234567890
405+
406+
remote_parent = :otel_tracer.from_remote_span(external_trace_id, external_parent_span_id, 1)
407+
ctx = Tracer.set_current_span(:otel_ctx.new(), remote_parent)
408+
409+
# Start an HTTP server span with the remote parent context
410+
Tracer.with_span ctx, "POST /api/users", %{
411+
kind: :server,
412+
attributes: %{
413+
HTTPAttributes.http_request_method() => :POST,
414+
URLAttributes.url_path() => "/api/users"
415+
}
416+
} do
417+
# Simulate child spans (database queries, etc.)
418+
Tracer.with_span "db.query:users", %{
419+
kind: :client,
420+
attributes: %{
421+
"db.system" => :postgresql,
422+
"db.statement" => "INSERT INTO users (name) VALUES ($1)"
423+
}
424+
} do
425+
Process.sleep(10)
426+
end
427+
end
428+
429+
# Should capture the HTTP request span as a transaction
430+
assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions()
431+
432+
# Verify the HTTP server span was removed from storage
433+
# (even though it was stored as a child span due to having a remote parent)
434+
http_server_span_id = transaction.contexts.trace.span_id
435+
remote_parent_span_id_str = "abcdef1234567890"
436+
437+
# The HTTP server span should not exist in storage anymore
438+
assert SpanStorage.get_root_span(http_server_span_id, table_name: table_name) == nil
439+
440+
# Check that it was also removed from child spans storage
441+
# We can't directly check if a specific child was removed, but we can verify
442+
# that get_child_spans for the remote parent returns empty (or doesn't include our span)
443+
remaining_children =
444+
SpanStorage.get_child_spans(remote_parent_span_id_str, table_name: table_name)
445+
446+
refute Enum.any?(remaining_children, fn span -> span.span_id == http_server_span_id end)
447+
448+
# Verify child spans of the HTTP server span were also removed
449+
assert [] == SpanStorage.get_child_spans(http_server_span_id, table_name: table_name)
450+
end
313451
end
314452
end

0 commit comments

Comments
 (0)