Skip to content

Commit 3536645

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

File tree

3 files changed

+182
-1
lines changed

3 files changed

+182
-1
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: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,5 +310,153 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do
310310

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

0 commit comments

Comments
 (0)