Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
## Unreleased

### Bug Fixes

- Wrong app_name used by Igniter in prod.exs ([#1](https://github.com/PJUllrich/sentry-elixir/pull/1))

#### Features

- Support for Distributed Tracing ([957](https://github.com/getsentry/sentry-elixir/pull/957))
- Support for Structured Logs ([#969](https://github.com/getsentry/sentry-elixir/pull/969))
- Support for Distributed Tracing ([957](https://github.com/getsentry/sentry-elixir/pull/957))
- Support for LiveView spans captured under single trace root ([#977](https://github.com/getsentry/sentry-elixir/pull/977))

### Bug Fixes

- Wrong app_name used by Igniter in prod.exs ([#1](https://github.com/PJUllrich/sentry-elixir/pull/1))

#### Various improvements

Expand Down
154 changes: 154 additions & 0 deletions lib/sentry/opentelemetry/live_view_propagator.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() and
Code.ensure_loaded?(Phoenix.LiveView) do
defmodule Sentry.OpenTelemetry.LiveViewPropagator do
@moduledoc """
Telemetry handler that propagates OpenTelemetry context to LiveView processes.

This module attaches telemetry handlers for LiveView lifecycle events
(mount, handle_params, handle_event) that run BEFORE `opentelemetry_phoenix`
creates spans, ensuring the correct parent trace context is attached.

## Why This Is Needed

When a browser makes an HTTP request with distributed tracing headers, the trace
context is correctly extracted for the initial request. However, Phoenix LiveView
spawns new BEAM processes for WebSocket connections that handle lifecycle callbacks.

`opentelemetry_phoenix` uses telemetry handlers to create spans for these events.
If we don't inject the parent context BEFORE those handlers run, each LiveView
span becomes a new root trace instead of being nested under the original HTTP request.

This module solves this by:
1. Using `Sentry.Plug.LiveViewContext` to store trace context in the session during the initial HTTP request
2. Attaching telemetry handlers with higher priority (registered first) than `opentelemetry_phoenix`
3. Extracting the context from the session and attaching it before `opentelemetry_phoenix` creates spans

## Usage

Call `setup/0` in your application's start function, **BEFORE** calling
`OpentelemetryPhoenix.setup/1`:

def start(_type, _args) do
# Set up Sentry's LiveView context propagation FIRST
Sentry.OpenTelemetry.LiveViewPropagator.setup()

# Then set up OpentelemetryPhoenix
OpentelemetryPhoenix.setup(adapter: :bandit)

children = [
# ...
]

Supervisor.start_link(children, strategy: :one_for_one)
end

Also add `Sentry.Plug.LiveViewContext` to your router pipeline:

pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
# ... other plugs
plug Sentry.Plug.LiveViewContext
end

*Available since v12.0.0.*
"""

@moduledoc since: "12.0.0"

require Logger
require Record

@span_ctx_fields Record.extract(:span_ctx,
from_lib: "opentelemetry_api/include/opentelemetry.hrl"
)
Record.defrecordp(:span_ctx, @span_ctx_fields)

@handler_id {__MODULE__, :live_view_context}

@doc """
Attaches telemetry handlers for LiveView context propagation.

Must be called BEFORE `OpentelemetryPhoenix.setup/1` to ensure handlers
run in the correct order.
"""
@spec setup() :: :ok
def setup do
events = [
[:phoenix, :live_view, :mount, :start],
[:phoenix, :live_view, :handle_params, :start],
[:phoenix, :live_view, :handle_event, :start],
[:phoenix, :live_component, :handle_event, :start]
]

_ =
:telemetry.attach_many(
@handler_id,
events,
&__MODULE__.handle_event/4,
%{}
)

:ok
end

@doc """
Detaches the telemetry handlers. Mainly useful for testing.
"""
@spec teardown() :: :ok | {:error, :not_found}
def teardown do
:telemetry.detach(@handler_id)
end

@doc false
def handle_event(_event, _measurements, %{socket: socket} = _meta, _config) do
case get_context_carrier(socket) do
carrier when is_map(carrier) and map_size(carrier) > 0 ->
current_span_ctx = :otel_tracer.current_span_ctx()

# Extract and attach the trace context from the session if needed
if has_valid_span?(current_span_ctx) do
:ok
else
new_ctx =
Sentry.OpenTelemetry.Propagator.extract(
:otel_ctx.get_current(),
carrier,
&Map.keys/1,
&map_getter/2,
[]
)

:otel_ctx.attach(new_ctx)
end

nil ->
:ok
end
end

# Try to get the carrier from socket private assigns
defp get_context_carrier(socket) do
session_key = Sentry.Plug.LiveViewContext.session_key()

case socket do
%{private: %{connect_info: %{session: session}}} when is_map(session) ->
Map.get(session, session_key)

_ ->
nil
end
end

# Check if span context has a valid (non-zero) trace ID
defp has_valid_span?(span_ctx(trace_id: trace_id)) when trace_id != 0, do: true
defp has_valid_span?(_), do: false

defp map_getter(key, carrier) do
case Map.fetch(carrier, key) do
{:ok, value} -> value
:error -> :undefined
end
end
end
end
70 changes: 48 additions & 22 deletions lib/sentry/opentelemetry/span_processor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
alias Sentry.{Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord}
alias Sentry.Interfaces.Span

# Extract span record fields to access parent_span_id in on_start
@span_fields Record.extract(:span, from_lib: "opentelemetry/include/otel_span.hrl")
Record.defrecordp(:span, @span_fields)

@impl :otel_span_processor
def on_start(_ctx, otel_span, _config) do
span_record = SpanRecord.new(otel_span)
Expand All @@ -32,25 +28,57 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
span_record = SpanRecord.new(otel_span)
SpanStorage.update_span(span_record)

if is_transaction_root?(span_record) do
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
Comment thread
solnic marked this conversation as resolved.

if transaction_root? do
build_and_send_transaction(span_record)
else
true
end
end

# Check if this is a root span (no parent) or a transaction root
#
# A span should be a transaction root if:
#
# 1. It has no parent (true root span)
# 2. OR it's a span with a remote parent span
#
defp is_transaction_root?(span_record) do
span_record.parent_span_id == nil or
not SpanStorage.span_exists?(span_record.parent_span_id)
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 or a LiveView span
defp server_span?(%{kind: :server} = span_record) do
http_server_span?(span_record) or liveview_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 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)
Expand All @@ -71,17 +99,15 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
{:error, :invalid_span}
end

# Clean up: remove the transaction root span and all its children
:ok = SpanStorage.remove_root_span(span_record.span_id)
:ok =
SpanStorage.remove_transaction_root_span(
span_record.span_id,
span_record.parent_span_id
)

result
end

@impl :otel_span_processor
def force_flush(_config) do
:ok
end

defp build_transaction(root_span_record, child_span_records) do
root_span = build_span(root_span_record)
Comment thread
sentry[bot] marked this conversation as resolved.
child_spans = Enum.map(child_span_records, &build_span(&1))
Expand Down
55 changes: 52 additions & 3 deletions lib/sentry/opentelemetry/span_storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
end
end

@doc """
Retrieves a span by its ID, regardless of whether it's a root or child span.
Returns nil if the span is not found.
"""
@spec get_span(String.t(), keyword()) :: SpanRecord.t() | nil
def get_span(span_id, opts \\ []) do
table_name = Keyword.get(opts, :table_name, default_table_name())

case :ets.lookup(table_name, {:root_span, span_id}) do
[{{:root_span, ^span_id}, span, _stored_at}] ->
span

[] ->
case :ets.match_object(table_name, {{:child_span, :_, span_id}, :_, :_}) do
[{_key, span, _stored_at} | _] -> span
[] -> nil
end
end
end

@spec store_span(SpanRecord.t(), keyword()) :: true
def store_span(span_data, opts \\ []) do
table_name = Keyword.get(opts, :table_name, default_table_name())
Expand Down Expand Up @@ -126,13 +146,42 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
:ok
end

@spec remove_transaction_root_span(String.t(), String.t() | nil, keyword()) :: :ok
def remove_transaction_root_span(span_id, parent_span_id, opts \\ []) do
table_name = Keyword.get(opts, :table_name, default_table_name())

_key =
if parent_span_id == nil do
:ets.select_delete(table_name, [{{{:root_span, span_id}, :_, :_}, [], [true]}])
else
:ets.delete(table_name, {:child_span, parent_span_id, span_id})
end

remove_child_spans(span_id, table_name: table_name)

:ok
end

@spec remove_child_spans(String.t(), keyword()) :: :ok
def remove_child_spans(parent_span_id, opts) do
table_name = Keyword.get(opts, :table_name, default_table_name())

:ets.select_delete(table_name, [
{{{:child_span, parent_span_id, :_}, :_, :_}, [], [true]}
])
:ets.match_object(table_name, {{:child_span, parent_span_id, :_}, :_, :_})
|> Enum.each(fn {key, span_data, _stored_at} ->
if span_data.end_time != nil do
:ets.delete(table_name, key)
end
end)

:ok
end

@spec remove_child_span(String.t(), String.t(), keyword()) :: :ok
def remove_child_span(parent_span_id, span_id, opts \\ []) do
table_name = Keyword.get(opts, :table_name, default_table_name())
key = {:child_span, parent_span_id, span_id}

:ets.delete(table_name, key)

:ok
end
Expand Down
Loading
Loading