Skip to content

Commit 858a67c

Browse files
committed
feat(tracing): nest LV spans under root traces
1 parent f20b400 commit 858a67c

11 files changed

Lines changed: 1142 additions & 243 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
# During static render (HTTP request phase), the HTTP span already covers
106+
# this phase, so we skip context propagation to avoid redundant work.
107+
#
108+
# For WebSocket connections, we need to propagate the trace context from
109+
# the session so spans are correctly parented to the original HTTP request.
110+
unless static_render?(socket) do
111+
propagate_context_if_needed(socket)
112+
end
113+
114+
:ok
115+
end
116+
117+
# Check if this is a static render (not connected via WebSocket)
118+
defp static_render?(socket) do
119+
# During static render, transport_pid is nil
120+
socket.transport_pid == nil
121+
end
122+
123+
defp propagate_context_if_needed(socket) do
124+
# Try to get trace context from socket's private assigns
125+
case get_context_carrier(socket) do
126+
nil ->
127+
:ok
128+
129+
carrier when is_map(carrier) and map_size(carrier) > 0 ->
130+
# Only propagate context if we don't already have a valid span attached.
131+
# This prevents overwriting existing trace context during nested operations.
132+
current_span_ctx = :otel_tracer.current_span_ctx()
133+
134+
_ =
135+
unless has_valid_span?(current_span_ctx) do
136+
# Extract and attach the trace context from the session
137+
ctx = :otel_ctx.get_current()
138+
139+
new_ctx =
140+
Sentry.OpenTelemetry.Propagator.extract(
141+
ctx,
142+
carrier,
143+
&map_keys/1,
144+
&map_getter/2,
145+
[]
146+
)
147+
148+
:otel_ctx.attach(new_ctx)
149+
end
150+
151+
:ok
152+
153+
_ ->
154+
:ok
155+
end
156+
end
157+
158+
# Try to get the carrier from socket private assigns
159+
defp get_context_carrier(socket) do
160+
session_key = Sentry.Plug.LiveViewContext.session_key()
161+
162+
# The session can be in different places depending on the connection type:
163+
# 1. WebSocket: socket.private.connect_info.session (map)
164+
# 2. Static render (test): socket.private.connect_info is a %Plug.Conn{}
165+
case socket do
166+
# WebSocket connection has session as a map
167+
%{private: %{connect_info: %{session: session}}} when is_map(session) ->
168+
Map.get(session, session_key)
169+
170+
# Static render (Phoenix.LiveViewTest) has connect_info as Plug.Conn
171+
%{private: %{connect_info: %Plug.Conn{private: %{plug_session: session}}}}
172+
when is_map(session) ->
173+
Map.get(session, session_key)
174+
175+
_ ->
176+
nil
177+
end
178+
end
179+
180+
# Check if span context has a valid (non-zero) trace ID
181+
defp has_valid_span?(:undefined), do: false
182+
defp has_valid_span?(span_ctx(trace_id: trace_id)) when trace_id != 0, do: true
183+
defp has_valid_span?(_), do: false
184+
185+
defp map_keys(carrier), do: Map.keys(carrier)
186+
187+
defp map_getter(key, carrier) do
188+
case Map.fetch(carrier, key) do
189+
{:ok, value} -> value
190+
:error -> :undefined
191+
end
192+
end
193+
end
194+
end

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 112 additions & 16 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,23 +28,92 @@ 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+
# Filter redundant LiveView spans from static renders.
32+
# During static render, the HTTP server span already covers the request,
33+
# so LiveView lifecycle spans (mount, handle_params, etc.) are duplicates.
34+
if skip_static_render_liveview_span?(span_record) do
35+
true
36+
else
37+
process_span(span_record)
38+
end
39+
end
40+
41+
# Skip LiveView spans that occur during static render (HTTP request phase).
42+
# These are redundant because the HTTP server span already covers this phase.
43+
# We detect this by checking if:
44+
# 1. The span is a LiveView lifecycle span (mount, handle_params, handle_event)
45+
# 2. It has a local parent span that is an HTTP server span
46+
defp skip_static_render_liveview_span?(span_record) do
47+
is_liveview_span?(span_record) and has_local_http_parent?(span_record)
48+
end
49+
50+
# Check if span name matches LiveView lifecycle patterns from opentelemetry_phoenix
51+
# Span names are like "MyAppWeb.SomeLive.mount", "MyAppWeb.SomeLive.handle_params", etc.
52+
# Note: handle_event spans have the event name appended like "Module.handle_event#event_name"
53+
defp is_liveview_span?(%{name: name}) when is_binary(name) do
54+
is_liveview_lifecycle_span?(name)
55+
end
56+
57+
defp is_liveview_span?(_), do: false
58+
59+
# Check if the span has a local parent that is an HTTP server span
60+
defp has_local_http_parent?(%{parent_span_id: nil}), do: false
61+
62+
defp has_local_http_parent?(%{parent_span_id: parent_span_id}) do
63+
case SpanStorage.get_span(parent_span_id) do
64+
nil -> false
65+
parent_span -> is_http_server_span?(parent_span)
66+
end
67+
end
68+
69+
defp is_http_server_span?(%{kind: :server, attributes: attributes}) do
70+
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
71+
end
72+
73+
defp is_http_server_span?(_), do: false
74+
75+
defp process_span(span_record) do
76+
SpanStorage.store_span(span_record)
77+
78+
# Check if this is a root span (no parent) or a transaction root
79+
#
80+
# A span should be a transaction root if:
81+
# 1. It has no parent (true root span)
82+
# 2. OR it's a server span with only a REMOTE parent (distributed tracing)
83+
#
84+
# A span should NOT be a transaction root if:
85+
# - It has a LOCAL parent (parent span exists in our SpanStorage)
86+
#
87+
# Note: LiveView spans during static render are filtered earlier by
88+
# skip_static_render_liveview_span?/1, so we don't need to handle them here.
89+
is_transaction_root =
90+
cond do
91+
# No parent = definitely a root
92+
span_record.parent_span_id == nil ->
93+
true
94+
95+
# Has a parent - check if it's local or remote
96+
true ->
97+
has_local_parent = has_local_parent_span?(span_record.parent_span_id)
98+
99+
if has_local_parent do
100+
# Parent exists locally - this is a child span, not a transaction root
101+
false
102+
else
103+
# Parent is remote (distributed tracing) - treat server spans as transaction roots
104+
is_server_span?(span_record)
105+
end
106+
end
107+
108+
if is_transaction_root do
36109
build_and_send_transaction(span_record)
37110
else
38111
true
39112
end
40113
end
41114

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)
115+
defp has_local_parent_span?(parent_span_id) do
116+
SpanStorage.span_exists?(parent_span_id)
52117
end
53118

54119
defp build_and_send_transaction(span_record) do
@@ -82,6 +147,31 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
82147
:ok
83148
end
84149

150+
# Helper function to detect if a span is a server span that should be
151+
# treated as a transaction root for distributed tracing.
152+
# This includes HTTP server request spans (have http.request.method attribute)
153+
# and LiveView lifecycle spans (mount, handle_params, handle_event)
154+
defp is_server_span?(%{kind: :server, attributes: attributes, name: name}) do
155+
# Check if it's an HTTP server request span (has http.request.method)
156+
# LiveView lifecycle spans (opentelemetry_phoenix uses kind: :server)
157+
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method())) or
158+
is_liveview_lifecycle_span?(name)
159+
end
160+
161+
defp is_server_span?(_), do: false
162+
163+
# Check if span name matches LiveView lifecycle patterns
164+
# Note: handle_event spans have the event name appended like "Module.handle_event#event_name"
165+
# So we check if the name contains these patterns, not just ends with them
166+
defp is_liveview_lifecycle_span?(name) when is_binary(name) do
167+
String.ends_with?(name, ".mount") or
168+
String.ends_with?(name, ".handle_params") or
169+
String.contains?(name, ".handle_event#") or
170+
String.ends_with?(name, ".handle_event")
171+
end
172+
173+
defp is_liveview_lifecycle_span?(_), do: false
174+
85175
defp build_transaction(root_span_record, child_span_records) do
86176
root_span = build_span(root_span_record)
87177
child_spans = Enum.map(child_span_records, &build_span(&1))
@@ -134,7 +224,13 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
134224
client_address =
135225
Map.get(span_record.attributes, to_string(ClientAttributes.client_address()))
136226

137-
url_path = Map.get(span_record.attributes, to_string(URLAttributes.url_path()))
227+
# Try multiple attributes for the URL path
228+
url_path =
229+
Map.get(span_record.attributes, to_string(URLAttributes.url_path())) ||
230+
Map.get(span_record.attributes, "url.full") ||
231+
Map.get(span_record.attributes, "http.target") ||
232+
Map.get(span_record.attributes, "http.route") ||
233+
span_record.name
138234

139235
# Build description with method and path
140236
description =

0 commit comments

Comments
 (0)