Skip to content

Commit 5c89d26

Browse files
authored
feat(tracing): nest LiveView spans under root traces (#977)
* chore: clean up span processor test a bit * feat(tracing): support for nested LiveView spans * fix(otel): handle root spans correctly when cleaning * chore(docs): remove outdated comment * chore(review): address PR review feedback - Simplify transaction_root? assignment by removing intermediate variable - Rename is_server_span?, is_http_server_span?, is_liveview_span? to remove is_ prefix per Elixir convention - Replace map_keys helper functions with direct &Map.keys/1 calls * chore(docs): update CHANGELOG.md
1 parent 8b3eb70 commit 5c89d26

13 files changed

Lines changed: 1547 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
## Unreleased
22

3-
### Bug Fixes
4-
5-
- Wrong app_name used by Igniter in prod.exs ([#1](https://github.com/PJUllrich/sentry-elixir/pull/1))
6-
73
#### Features
84

9-
- Support for Distributed Tracing ([957](https://github.com/getsentry/sentry-elixir/pull/957))
105
- Support for Structured Logs ([#969](https://github.com/getsentry/sentry-elixir/pull/969))
6+
- Support for Distributed Tracing ([957](https://github.com/getsentry/sentry-elixir/pull/957))
7+
- Support for LiveView spans captured under single trace root ([#977](https://github.com/getsentry/sentry-elixir/pull/977))
8+
9+
### Bug Fixes
10+
11+
- Wrong app_name used by Igniter in prod.exs ([#1](https://github.com/PJUllrich/sentry-elixir/pull/1))
1112

1213
#### Various improvements
1314

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() and
2+
Code.ensure_loaded?(Phoenix.LiveView) do
3+
defmodule Sentry.OpenTelemetry.LiveViewPropagator do
4+
@moduledoc """
5+
Telemetry handler that propagates OpenTelemetry context to LiveView processes.
6+
7+
This module attaches telemetry handlers for LiveView lifecycle events
8+
(mount, handle_params, handle_event) that run BEFORE `opentelemetry_phoenix`
9+
creates spans, ensuring the correct parent trace context is attached.
10+
11+
## Why This Is Needed
12+
13+
When a browser makes an HTTP request with distributed tracing headers, the trace
14+
context is correctly extracted for the initial request. However, Phoenix LiveView
15+
spawns new BEAM processes for WebSocket connections that handle lifecycle callbacks.
16+
17+
`opentelemetry_phoenix` uses telemetry handlers to create spans for these events.
18+
If we don't inject the parent context BEFORE those handlers run, each LiveView
19+
span becomes a new root trace instead of being nested under the original HTTP request.
20+
21+
This module solves this by:
22+
1. Using `Sentry.Plug.LiveViewContext` to store trace context in the session during the initial HTTP request
23+
2. Attaching telemetry handlers with higher priority (registered first) than `opentelemetry_phoenix`
24+
3. Extracting the context from the session and attaching it before `opentelemetry_phoenix` creates spans
25+
26+
## Usage
27+
28+
Call `setup/0` in your application's start function, **BEFORE** calling
29+
`OpentelemetryPhoenix.setup/1`:
30+
31+
def start(_type, _args) do
32+
# Set up Sentry's LiveView context propagation FIRST
33+
Sentry.OpenTelemetry.LiveViewPropagator.setup()
34+
35+
# Then set up OpentelemetryPhoenix
36+
OpentelemetryPhoenix.setup(adapter: :bandit)
37+
38+
children = [
39+
# ...
40+
]
41+
42+
Supervisor.start_link(children, strategy: :one_for_one)
43+
end
44+
45+
Also add `Sentry.Plug.LiveViewContext` to your router pipeline:
46+
47+
pipeline :browser do
48+
plug :accepts, ["html"]
49+
plug :fetch_session
50+
# ... other plugs
51+
plug Sentry.Plug.LiveViewContext
52+
end
53+
54+
*Available since v12.0.0.*
55+
"""
56+
57+
@moduledoc since: "12.0.0"
58+
59+
require Logger
60+
require Record
61+
62+
@span_ctx_fields Record.extract(:span_ctx,
63+
from_lib: "opentelemetry_api/include/opentelemetry.hrl"
64+
)
65+
Record.defrecordp(:span_ctx, @span_ctx_fields)
66+
67+
@handler_id {__MODULE__, :live_view_context}
68+
69+
@doc """
70+
Attaches telemetry handlers for LiveView context propagation.
71+
72+
Must be called BEFORE `OpentelemetryPhoenix.setup/1` to ensure handlers
73+
run in the correct order.
74+
"""
75+
@spec setup() :: :ok
76+
def setup do
77+
events = [
78+
[:phoenix, :live_view, :mount, :start],
79+
[:phoenix, :live_view, :handle_params, :start],
80+
[:phoenix, :live_view, :handle_event, :start],
81+
[:phoenix, :live_component, :handle_event, :start]
82+
]
83+
84+
_ =
85+
:telemetry.attach_many(
86+
@handler_id,
87+
events,
88+
&__MODULE__.handle_event/4,
89+
%{}
90+
)
91+
92+
:ok
93+
end
94+
95+
@doc """
96+
Detaches the telemetry handlers. Mainly useful for testing.
97+
"""
98+
@spec teardown() :: :ok | {:error, :not_found}
99+
def teardown do
100+
:telemetry.detach(@handler_id)
101+
end
102+
103+
@doc false
104+
def handle_event(_event, _measurements, %{socket: socket} = _meta, _config) do
105+
case get_context_carrier(socket) do
106+
carrier when is_map(carrier) and map_size(carrier) > 0 ->
107+
current_span_ctx = :otel_tracer.current_span_ctx()
108+
109+
# Extract and attach the trace context from the session if needed
110+
if has_valid_span?(current_span_ctx) do
111+
:ok
112+
else
113+
new_ctx =
114+
Sentry.OpenTelemetry.Propagator.extract(
115+
:otel_ctx.get_current(),
116+
carrier,
117+
&Map.keys/1,
118+
&map_getter/2,
119+
[]
120+
)
121+
122+
:otel_ctx.attach(new_ctx)
123+
end
124+
125+
nil ->
126+
:ok
127+
end
128+
end
129+
130+
# Try to get the carrier from socket private assigns
131+
defp get_context_carrier(socket) do
132+
session_key = Sentry.Plug.LiveViewContext.session_key()
133+
134+
case socket do
135+
%{private: %{connect_info: %{session: session}}} when is_map(session) ->
136+
Map.get(session, session_key)
137+
138+
_ ->
139+
nil
140+
end
141+
end
142+
143+
# Check if span context has a valid (non-zero) trace ID
144+
defp has_valid_span?(span_ctx(trace_id: trace_id)) when trace_id != 0, do: true
145+
defp has_valid_span?(_), do: false
146+
147+
defp map_getter(key, carrier) do
148+
case Map.fetch(carrier, key) do
149+
{:ok, value} -> value
150+
:error -> :undefined
151+
end
152+
end
153+
end
154+
end

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
1616
alias Sentry.{Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord}
1717
alias Sentry.Interfaces.Span
1818

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-
2319
@impl :otel_span_processor
2420
def on_start(_ctx, otel_span, _config) do
2521
span_record = SpanRecord.new(otel_span)
@@ -32,25 +28,57 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
3228
span_record = SpanRecord.new(otel_span)
3329
SpanStorage.update_span(span_record)
3430

35-
if is_transaction_root?(span_record) do
31+
process_span(span_record)
32+
end
33+
34+
@impl :otel_span_processor
35+
def force_flush(_config) do
36+
:ok
37+
end
38+
39+
defp process_span(span_record) do
40+
transaction_root? =
41+
cond do
42+
# No parent = definitely a root
43+
span_record.parent_span_id == nil ->
44+
true
45+
46+
# Has a parent - check if it's local or remote
47+
has_local_parent_span?(span_record.parent_span_id) ->
48+
# Parent exists locally - this is a child span, not a transaction root
49+
false
50+
51+
true ->
52+
# Parent is remote (distributed tracing) - treat server spans as transaction roots
53+
server_span?(span_record)
54+
end
55+
56+
if transaction_root? do
3657
build_and_send_transaction(span_record)
3758
else
3859
true
3960
end
4061
end
4162

42-
# Check if this is a root span (no parent) or a transaction root
43-
#
44-
# A span should be a transaction root if:
45-
#
46-
# 1. It has no parent (true root span)
47-
# 2. OR it's a span with a remote parent span
48-
#
49-
defp is_transaction_root?(span_record) do
50-
span_record.parent_span_id == nil or
51-
not SpanStorage.span_exists?(span_record.parent_span_id)
63+
defp has_local_parent_span?(parent_span_id) do
64+
SpanStorage.span_exists?(parent_span_id)
65+
end
66+
67+
# Check if it's an HTTP server request span or a LiveView span
68+
defp server_span?(%{kind: :server} = span_record) do
69+
http_server_span?(span_record) or liveview_span?(span_record)
70+
end
71+
72+
defp server_span?(_), do: false
73+
74+
defp http_server_span?(%{kind: :server, attributes: attributes}) do
75+
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
5276
end
5377

78+
# Check if span name matches LiveView lifecycle patterns
79+
defp liveview_span?(%{origin: "opentelemetry_phoenix"}), do: true
80+
defp liveview_span?(_), do: false
81+
5482
defp build_and_send_transaction(span_record) do
5583
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
5684
transaction = build_transaction(span_record, child_span_records)
@@ -71,17 +99,15 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
7199
{:error, :invalid_span}
72100
end
73101

74-
# Clean up: remove the transaction root span and all its children
75-
:ok = SpanStorage.remove_root_span(span_record.span_id)
102+
:ok =
103+
SpanStorage.remove_transaction_root_span(
104+
span_record.span_id,
105+
span_record.parent_span_id
106+
)
76107

77108
result
78109
end
79110

80-
@impl :otel_span_processor
81-
def force_flush(_config) do
82-
:ok
83-
end
84-
85111
defp build_transaction(root_span_record, child_span_records) do
86112
root_span = build_span(root_span_record)
87113
child_spans = Enum.map(child_span_records, &build_span(&1))

lib/sentry/opentelemetry/span_storage.ex

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
5353
end
5454
end
5555

56+
@doc """
57+
Retrieves a span by its ID, regardless of whether it's a root or child span.
58+
Returns nil if the span is not found.
59+
"""
60+
@spec get_span(String.t(), keyword()) :: SpanRecord.t() | nil
61+
def get_span(span_id, opts \\ []) do
62+
table_name = Keyword.get(opts, :table_name, default_table_name())
63+
64+
case :ets.lookup(table_name, {:root_span, span_id}) do
65+
[{{:root_span, ^span_id}, span, _stored_at}] ->
66+
span
67+
68+
[] ->
69+
case :ets.match_object(table_name, {{:child_span, :_, span_id}, :_, :_}) do
70+
[{_key, span, _stored_at} | _] -> span
71+
[] -> nil
72+
end
73+
end
74+
end
75+
5676
@spec store_span(SpanRecord.t(), keyword()) :: true
5777
def store_span(span_data, opts \\ []) do
5878
table_name = Keyword.get(opts, :table_name, default_table_name())
@@ -126,13 +146,42 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
126146
:ok
127147
end
128148

149+
@spec remove_transaction_root_span(String.t(), String.t() | nil, keyword()) :: :ok
150+
def remove_transaction_root_span(span_id, parent_span_id, opts \\ []) do
151+
table_name = Keyword.get(opts, :table_name, default_table_name())
152+
153+
_key =
154+
if parent_span_id == nil do
155+
:ets.select_delete(table_name, [{{{:root_span, span_id}, :_, :_}, [], [true]}])
156+
else
157+
:ets.delete(table_name, {:child_span, parent_span_id, span_id})
158+
end
159+
160+
remove_child_spans(span_id, table_name: table_name)
161+
162+
:ok
163+
end
164+
129165
@spec remove_child_spans(String.t(), keyword()) :: :ok
130166
def remove_child_spans(parent_span_id, opts) do
131167
table_name = Keyword.get(opts, :table_name, default_table_name())
132168

133-
:ets.select_delete(table_name, [
134-
{{{:child_span, parent_span_id, :_}, :_, :_}, [], [true]}
135-
])
169+
:ets.match_object(table_name, {{:child_span, parent_span_id, :_}, :_, :_})
170+
|> Enum.each(fn {key, span_data, _stored_at} ->
171+
if span_data.end_time != nil do
172+
:ets.delete(table_name, key)
173+
end
174+
end)
175+
176+
:ok
177+
end
178+
179+
@spec remove_child_span(String.t(), String.t(), keyword()) :: :ok
180+
def remove_child_span(parent_span_id, span_id, opts \\ []) do
181+
table_name = Keyword.get(opts, :table_name, default_table_name())
182+
key = {:child_span, parent_span_id, span_id}
183+
184+
:ets.delete(table_name, key)
136185

137186
:ok
138187
end

0 commit comments

Comments
 (0)