Current section

Files

Jump to
sentry lib sentry opentelemetry span_processor.ex
Raw

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