Packages
sentry
13.0.1
13.3.0
13.2.0
13.1.0
13.0.1
13.0.0
12.0.3
12.0.2
12.0.1
12.0.0
11.0.4
11.0.3
11.0.2
11.0.1
11.0.0
10.10.0
10.9.0
10.8.1
10.8.0
10.7.1
10.7.0
10.6.2
10.6.1
10.6.0
10.5.0
10.4.0
10.3.0
10.2.1
10.2.0
10.2.0-rc.2
10.2.0-rc.1
10.1.0
10.0.3
10.0.2
10.0.1
10.0.0
9.1.0
9.0.0
8.1.0
8.0.6
8.0.5
8.0.4
8.0.3
8.0.2
8.0.1
8.0.0
8.0.0-rc.2
8.0.0-rc.1
8.0.0-rc.0
retired
7.2.5
7.2.4
7.2.3
7.2.2
7.2.1
7.2.0
7.1.0
7.0.6
7.0.5
7.0.4
7.0.3
7.0.2
7.0.1
7.0.0
6.4.2
6.4.1
6.4.0
6.3.0
6.2.1
6.2.0
6.1.0
6.0.5
6.0.4
6.0.3
6.0.2
6.0.1
6.0.0
5.0.1
5.0.0
4.0.3
4.0.2
4.0.1
4.0.0
3.0.0
2.2.0
2.1.0
2.0.2
2.0.1
2.0.0
1.1.2
1.1.1
1.1.0
1.0.0
0.3.2
0.3.1
0.3.0
0.2.0
0.1.3
0.1.2
0.1.1
0.1.0
The Official Elixir client for Sentry
Current section
Files
Jump to
Current section
Files
lib/sentry/opentelemetry/span_processor.ex
if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
defmodule Sentry.OpenTelemetry.SpanProcessor do
@moduledoc false
@behaviour :otel_span_processor
require OpenTelemetry.SemConv.ClientAttributes, as: ClientAttributes
require OpenTelemetry.SemConv.Incubating.DBAttributes, as: DBAttributes
require OpenTelemetry.SemConv.Incubating.HTTPAttributes, as: HTTPAttributes
require OpenTelemetry.SemConv.Incubating.URLAttributes, as: URLAttributes
require OpenTelemetry.SemConv.Incubating.MessagingAttributes, as: MessagingAttributes
require Logger
require Record
alias Sentry.{Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord}
alias Sentry.Interfaces.Span
@impl :otel_span_processor
def on_start(_ctx, otel_span, _config) do
span_record = SpanRecord.new(otel_span)
SpanStorage.store_span(span_record)
otel_span
end
@impl :otel_span_processor
def on_end(otel_span, _config) do
span_record = SpanRecord.new(otel_span)
SpanStorage.update_span(span_record)
process_span(span_record)
end
@impl :otel_span_processor
def force_flush(_config) do
:ok
end
defp process_span(span_record) do
transaction_root? =
cond do
# No parent = definitely a root
span_record.parent_span_id == nil ->
true
# Has a parent - check if it's local or remote
has_local_parent_span?(span_record.parent_span_id) ->
# Parent exists locally - this is a child span, not a transaction root
false
true ->
# Parent is remote (distributed tracing) - treat server spans as transaction roots
server_span?(span_record)
end
if transaction_root? do
build_and_send_transaction(span_record)
else
true
end
end
defp has_local_parent_span?(parent_span_id) do
SpanStorage.span_exists?(parent_span_id)
end
# Check if it's an HTTP server request span, a LiveView span, or an Oban consumer span
defp server_span?(%{kind: :server} = span_record) do
http_server_span?(span_record) or liveview_span?(span_record)
end
defp server_span?(%{kind: :consumer} = span_record) do
oban_consumer_span?(span_record)
end
defp server_span?(_), do: false
defp http_server_span?(%{kind: :server, attributes: attributes}) do
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
end
# Check if span name matches LiveView lifecycle patterns
defp liveview_span?(%{origin: "opentelemetry_phoenix"}), do: true
defp liveview_span?(_), do: false
defp oban_consumer_span?(%{kind: :consumer, attributes: attributes}) do
Map.get(attributes, to_string(MessagingAttributes.messaging_system())) == :oban
end
defp build_and_send_transaction(span_record) do
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
transaction = build_transaction(span_record, child_span_records)
result =
case Sentry.send_transaction(transaction) do
{:ok, _id} ->
true
:ignored ->
true
:excluded ->
true
{:error, error} ->
Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}")
{:error, :invalid_span}
end
:ok =
SpanStorage.remove_transaction_root_span(
span_record.span_id,
span_record.parent_span_id
)
result
end
defp build_transaction(root_span_record, child_span_records) do
root_span = build_span(root_span_record)
child_spans = Enum.map(child_span_records, &build_span(&1))
Transaction.new(%{
span_id: root_span.span_id,
transaction: transaction_name(root_span_record),
transaction_info: %{source: :custom},
start_timestamp: root_span_record.start_time,
timestamp: root_span_record.end_time,
contexts: %{
trace: build_trace_context(root_span_record)
},
spans: child_spans
})
end
defp transaction_name(
%{attributes: %{unquote(to_string(MessagingAttributes.messaging_system())) => :oban}} =
span_record
) do
span_record.attributes["oban.job.worker"]
end
defp transaction_name(span_record), do: span_record.name
defp build_trace_context(span_record) do
{op, description} = get_op_description(span_record)
context = %{
trace_id: span_record.trace_id,
span_id: span_record.span_id,
parent_span_id: span_record.parent_span_id,
op: op,
description: description,
origin: span_record.origin,
data: filter_attributes(span_record.attributes)
}
# Add links if present (for root spans, links go in trace context)
if span_record.links != [] do
Map.put(context, :links, format_links(span_record.links))
else
context
end
end
defp get_op_description(
%{
attributes: %{
unquote(to_string(HTTPAttributes.http_request_method())) => http_request_method
}
} = span_record
) do
op = "http.#{span_record.kind}"
client_address =
Map.get(span_record.attributes, to_string(ClientAttributes.client_address()))
url_path = Map.get(span_record.attributes, to_string(URLAttributes.url_path()))
# Build description with method and path
description =
case url_path do
nil -> to_string(http_request_method)
path -> "#{http_request_method} #{path}"
end
description =
if client_address do
"#{description} from #{client_address}"
else
description
end
{op, description}
end
defp get_op_description(
%{attributes: %{unquote(to_string(DBAttributes.db_system())) => _db_system}} =
span_record
) do
db_query_text = Map.get(span_record.attributes, "db.statement")
{"db", db_query_text}
end
defp get_op_description(%{
attributes:
%{unquote(to_string(MessagingAttributes.messaging_system())) => :oban} = attributes
}) do
{"queue.process", attributes["oban.job.worker"]}
end
defp get_op_description(span_record) do
{span_record.name, span_record.name}
end
defp build_span(span_record) do
{op, description} = get_op_description(span_record)
filtered_attributes = filter_attributes(span_record.attributes)
span = %Span{
op: op,
description: description,
start_timestamp: span_record.start_time,
timestamp: span_record.end_time,
trace_id: span_record.trace_id,
span_id: span_record.span_id,
parent_span_id: span_record.parent_span_id,
origin: span_record.origin,
data: Map.put(filtered_attributes, "otel.kind", span_record.kind),
status: span_status(span_record)
}
# Add links if present (for child spans, links go in the span itself).
# When links is empty, the span retains links: nil (struct default), which is
# consistent with how other optional Span fields (status, tags, op) are handled —
# they are also sent as null via Map.from_struct/1 in Transaction.to_payload/1.
if span_record.links != [] do
%{span | links: format_links(span_record.links)}
else
span
end
end
defp span_status(%{
attributes: %{
unquote(to_string(HTTPAttributes.http_response_status_code())) =>
http_response_status_code
}
}) do
to_status(http_response_status_code)
end
defp span_status(_span_record), do: nil
# WebSocket upgrade spans doesn't have a HTTP status
defp to_status(nil), do: nil
defp to_status(status) when status in 200..299, do: "ok"
for {status, string} <- %{
400 => "invalid_argument",
401 => "unauthenticated",
403 => "permission_denied",
404 => "not_found",
409 => "already_exists",
429 => "resource_exhausted",
499 => "cancelled",
500 => "internal_error",
501 => "unimplemented",
503 => "unavailable",
504 => "deadline_exceeded"
} do
defp to_status(unquote(status)), do: unquote(string)
end
defp to_status(_any), do: "unknown_error"
defp filter_attributes(attributes) do
attributes
|> Enum.reject(fn {key, value} ->
case {key, value} do
{"db.url", "ecto:"} -> true
{"db.url", nil} -> true
{"db.url", ""} -> true
_ -> false
end
end)
|> Map.new()
end
# Format span links according to Sentry spec
# https://develop.sentry.dev/sdk/telemetry/traces/span-links/
#
# Note: The spec defines an optional `sampled` boolean, but the OTel link record
# only exposes `tracestate` (vendor key-value pairs), not `trace_flags` (which
# contains the sampled bit). The sampled field cannot be extracted from the
# current OTel Erlang SDK link record structure.
defp format_links(links) do
Enum.map(links, fn link ->
formatted = %{
span_id: link.span_id,
trace_id: link.trace_id
}
# Add attributes if present
if map_size(link.attributes) > 0 do
Map.put(formatted, :attributes, link.attributes)
else
formatted
end
end)
end
end
end