Skip to content

Commit f328cab

Browse files
committed
feat(tracing): support for distributed tracing
1 parent cf5ad4f commit f328cab

File tree

5 files changed

+904
-30
lines changed

5 files changed

+904
-30
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
2+
defmodule Sentry.OpenTelemetry.Propagator do
3+
@moduledoc """
4+
OpenTelemetry propagator for Sentry distributed tracing.
5+
6+
This propagator implements the `sentry-trace` and `sentry-baggage` header propagation
7+
to enable distributed tracing across service boundaries. It follows the W3C Trace Context.
8+
"""
9+
10+
import Bitwise
11+
12+
require Record
13+
require OpenTelemetry.Tracer, as: Tracer
14+
15+
@behaviour :otel_propagator_text_map
16+
17+
@fields Record.extract(:span_ctx, from_lib: "opentelemetry_api/include/opentelemetry.hrl")
18+
Record.defrecordp(:span_ctx, @fields)
19+
20+
@sentry_trace_key "sentry-trace"
21+
@sentry_baggage_key "baggage"
22+
@sentry_trace_ctx_key :"sentry-trace"
23+
@sentry_baggage_ctx_key :"sentry-baggage"
24+
25+
@impl true
26+
def fields(_opts) do
27+
[@sentry_trace_key, @sentry_baggage_key]
28+
end
29+
30+
@impl true
31+
def inject(ctx, carrier, setter, _opts) do
32+
case Tracer.current_span_ctx(ctx) do
33+
span_ctx(trace_id: tid, span_id: sid, trace_flags: flags) when tid != 0 and sid != 0 ->
34+
sentry_trace_header = encode_sentry_trace({tid, sid, flags})
35+
carrier = setter.(@sentry_trace_key, sentry_trace_header, carrier)
36+
37+
baggage_value = :otel_ctx.get_value(ctx, @sentry_baggage_ctx_key, :not_found)
38+
39+
if is_binary(baggage_value) and baggage_value != :not_found do
40+
setter.(@sentry_baggage_key, baggage_value, carrier)
41+
else
42+
carrier
43+
end
44+
45+
_ ->
46+
carrier
47+
end
48+
end
49+
50+
@impl true
51+
def extract(ctx, carrier, _keys_fun, getter, _opts) do
52+
case getter.(@sentry_trace_key, carrier) do
53+
:undefined ->
54+
ctx
55+
56+
header when is_binary(header) ->
57+
case decode_sentry_trace(header) do
58+
{:ok, {trace_hex, span_hex, sampled}} ->
59+
ctx =
60+
ctx
61+
|> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled})
62+
|> maybe_set_baggage(getter.(@sentry_baggage_key, carrier))
63+
64+
trace_id = hex_to_int(trace_hex)
65+
span_id = hex_to_int(span_hex)
66+
67+
# Create a remote, sampled parent span in the OTEL context.
68+
# We will set to "always sample" because Sentry will decide real sampling
69+
remote_span_ctx = :otel_tracer.from_remote_span(trace_id, span_id, 1)
70+
71+
Tracer.set_current_span(ctx, remote_span_ctx)
72+
73+
{:error, _reason} ->
74+
ctx
75+
end
76+
77+
_ ->
78+
ctx
79+
end
80+
end
81+
82+
# Encode trace ID, span ID, and sampled flag to sentry-trace header format
83+
# Format: {trace_id}-{span_id}-{sampled}
84+
defp encode_sentry_trace({trace_id_int, span_id_int, trace_flags}) do
85+
sampled = if (trace_flags &&& 1) == 1, do: "1", else: "0"
86+
int_to_hex(trace_id_int, 16) <> "-" <> int_to_hex(span_id_int, 8) <> "-" <> sampled
87+
end
88+
89+
# Decode sentry-trace header
90+
# Format: {trace_id}-{span_id}-{sampled} or {trace_id}-{span_id}
91+
defp decode_sentry_trace(
92+
<<trace_hex::binary-size(32), "-", span_hex::binary-size(16), "-",
93+
sampled::binary-size(1)>>
94+
) do
95+
{:ok, {trace_hex, span_hex, sampled == "1"}}
96+
end
97+
98+
defp decode_sentry_trace(<<trace_hex::binary-size(32), "-", span_hex::binary-size(16)>>) do
99+
{:ok, {trace_hex, span_hex, false}}
100+
end
101+
102+
defp decode_sentry_trace(_invalid) do
103+
{:error, :invalid_format}
104+
end
105+
106+
defp maybe_set_baggage(ctx, :undefined), do: ctx
107+
defp maybe_set_baggage(ctx, ""), do: ctx
108+
defp maybe_set_baggage(ctx, nil), do: ctx
109+
110+
defp maybe_set_baggage(ctx, baggage) when is_binary(baggage) do
111+
:otel_ctx.set_value(ctx, @sentry_baggage_ctx_key, baggage)
112+
end
113+
114+
# Convert hex string to integer
115+
defp hex_to_int(hex) do
116+
hex
117+
|> Base.decode16!(case: :mixed)
118+
|> :binary.decode_unsigned()
119+
end
120+
121+
# Convert integer to hex string with padding
122+
defp int_to_hex(value, num_bytes) do
123+
value
124+
|> :binary.encode_unsigned()
125+
|> bin_pad_left(num_bytes)
126+
|> Base.encode16(case: :lower)
127+
end
128+
129+
# Pad binary to specified number of bytes
130+
defp bin_pad_left(bin, total_bytes) do
131+
missing = total_bytes - byte_size(bin)
132+
if missing > 0, do: :binary.copy(<<0>>, missing) <> bin, else: bin
133+
end
134+
end
135+
end

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ 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
2125
otel_span
@@ -24,42 +28,93 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
2428
@impl :otel_span_processor
2529
def on_end(otel_span, _config) do
2630
span_record = SpanRecord.new(otel_span)
31+
process_span(span_record)
32+
end
2733

34+
defp process_span(span_record) do
2835
SpanStorage.store_span(span_record)
2936

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)
37+
# Check if this is a root span (no parent) or a transaction root
38+
#
39+
# A span should be a transaction root if:
40+
# 1. It has no parent (true root span)
41+
# 2. OR it's a server span with only a REMOTE parent (distributed tracing)
42+
#
43+
# A span should NOT be a transaction root if:
44+
# - It has a LOCAL parent (parent span exists in our SpanStorage)
45+
is_transaction_root =
46+
cond do
47+
# No parent = definitely a root
48+
span_record.parent_span_id == nil ->
49+
true
50+
51+
# Has a parent - check if it's local or remote
52+
true ->
53+
has_local_parent = has_local_parent_span?(span_record.parent_span_id)
54+
55+
if has_local_parent do
56+
# Parent exists locally - this is a child span, not a transaction root
57+
false
58+
else
59+
# Parent is remote (distributed tracing) - treat server spans as transaction roots
60+
is_server_span?(span_record)
61+
end
62+
end
3363

34-
result =
35-
case Sentry.send_transaction(transaction) do
36-
{:ok, _id} ->
37-
true
64+
if is_transaction_root do
65+
build_and_send_transaction(span_record)
66+
else
67+
true
68+
end
69+
end
3870

39-
:ignored ->
40-
true
71+
defp build_and_send_transaction(span_record) do
72+
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
73+
transaction = build_transaction(span_record, child_span_records)
4174

42-
:excluded ->
43-
true
75+
result =
76+
case Sentry.send_transaction(transaction) do
77+
{:ok, _id} ->
78+
true
4479

45-
{:error, error} ->
46-
Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}")
47-
{:error, :invalid_span}
48-
end
80+
:ignored ->
81+
true
4982

50-
:ok = SpanStorage.remove_root_span(span_record.span_id)
83+
:excluded ->
84+
true
5185

52-
result
53-
else
54-
true
55-
end
86+
{:error, error} ->
87+
Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}")
88+
{:error, :invalid_span}
89+
end
90+
91+
# Clean up: remove the transaction root span and all its children
92+
:ok = SpanStorage.remove_root_span(span_record.span_id)
93+
94+
result
5695
end
5796

5897
@impl :otel_span_processor
5998
def force_flush(_config) do
6099
:ok
61100
end
62101

102+
# Checks if a parent span exists in our local SpanStorage
103+
# This helps distinguish between:
104+
# - Local parents: span exists in storage (same service)
105+
# - Remote parents: span doesn't exist in storage (distributed tracing from another service)
106+
defp has_local_parent_span?(parent_span_id) do
107+
SpanStorage.span_exists?(parent_span_id)
108+
end
109+
110+
# Helper function to detect if a span is a server span that should be
111+
# treated as a transaction root for distributed tracing.
112+
defp is_server_span?(%{kind: :server, attributes: attributes}) do
113+
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
114+
end
115+
116+
defp is_server_span?(_), do: false
117+
63118
defp build_transaction(root_span_record, child_span_records) do
64119
root_span = build_span(root_span_record)
65120
child_spans = Enum.map(child_span_records, &build_span(&1))
@@ -114,10 +169,19 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
114169

115170
url_path = Map.get(span_record.attributes, to_string(URLAttributes.url_path()))
116171

172+
# Build description with method and path
117173
description =
118-
to_string(http_request_method) <>
119-
((client_address && " from #{client_address}") || "") <>
120-
((url_path && " #{url_path}") || "")
174+
case url_path do
175+
nil -> to_string(http_request_method)
176+
path -> "#{http_request_method} #{path}"
177+
end
178+
179+
description =
180+
if client_address do
181+
"#{description} from #{client_address}"
182+
else
183+
description
184+
end
121185

122186
{op, description}
123187
end

lib/sentry/opentelemetry/span_storage.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
3737
{:noreply, state}
3838
end
3939

40+
@spec span_exists?(String.t(), keyword()) :: boolean()
41+
def span_exists?(span_id, opts \\ []) do
42+
table_name = Keyword.get(opts, :table_name, default_table_name())
43+
44+
case :ets.lookup(table_name, {:root_span, span_id}) do
45+
[{{:root_span, ^span_id}, _span, _stored_at}] ->
46+
true
47+
48+
[] ->
49+
case :ets.match_object(table_name, {{:child_span, :_, span_id}, :_, :_}) do
50+
[] -> false
51+
_ -> true
52+
end
53+
end
54+
end
55+
4056
@spec store_span(SpanRecord.t(), keyword()) :: true
4157
def store_span(span_data, opts \\ []) do
4258
table_name = Keyword.get(opts, :table_name, default_table_name())

0 commit comments

Comments
 (0)