Skip to content

Commit 8250b30

Browse files
committed
wip - live view trace propagation
1 parent 22dfc60 commit 8250b30

File tree

6 files changed

+615
-13
lines changed

6 files changed

+615
-13
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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 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.
20+
21+
This module solves this by:
22+
1. Attaching telemetry handlers with a lower priority (registered first)
23+
2. Storing the trace context in process dictionary via `Sentry.Plug.LiveViewContext`
24+
3. Extracting and attaching the context 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 v10.x.0.*
55+
"""
56+
57+
@moduledoc since: "10.8.0"
58+
59+
require Logger
60+
61+
@handler_id {__MODULE__, :live_view_context}
62+
63+
@doc """
64+
Attaches telemetry handlers for LiveView context propagation.
65+
66+
Must be called BEFORE `OpentelemetryPhoenix.setup/1` to ensure handlers
67+
run in the correct order.
68+
"""
69+
@spec setup() :: :ok
70+
def setup do
71+
events = [
72+
[:phoenix, :live_view, :mount, :start],
73+
[:phoenix, :live_view, :handle_params, :start],
74+
[:phoenix, :live_view, :handle_event, :start],
75+
[:phoenix, :live_component, :handle_event, :start]
76+
]
77+
78+
:telemetry.attach_many(
79+
@handler_id,
80+
events,
81+
&__MODULE__.handle_event/4,
82+
%{}
83+
)
84+
85+
:ok
86+
end
87+
88+
@doc """
89+
Detaches the telemetry handlers. Mainly useful for testing.
90+
"""
91+
@spec teardown() :: :ok | {:error, :not_found}
92+
def teardown do
93+
:telemetry.detach(@handler_id)
94+
end
95+
96+
@doc false
97+
def handle_event(_event, _measurements, %{socket: socket} = _meta, _config) do
98+
# During static render (HTTP request phase), we mark spans to be filtered
99+
# since the HTTP span already covers this phase. Real LiveView interactions
100+
# happen over WebSocket.
101+
if static_render?(socket) do
102+
# Store in process dict that this is a static render - the span processor
103+
# can check this to filter out redundant LiveView spans
104+
Process.put(:sentry_lv_static_render, true)
105+
:ok
106+
else
107+
# WebSocket connection - clear the static render flag and propagate context
108+
Process.delete(:sentry_lv_static_render)
109+
propagate_context_if_needed(socket)
110+
end
111+
end
112+
113+
@doc """
114+
Returns true if the current process is handling a static LiveView render.
115+
Used by the span processor to filter redundant spans.
116+
"""
117+
def static_render? do
118+
Process.get(:sentry_lv_static_render, false)
119+
end
120+
121+
# Check if this is a static render (not connected via WebSocket)
122+
defp static_render?(socket) do
123+
# During static render, transport_pid is nil
124+
socket.transport_pid == nil
125+
end
126+
127+
defp propagate_context_if_needed(socket) do
128+
# Try to get trace context from socket's private assigns
129+
case get_context_carrier(socket) do
130+
nil ->
131+
:ok
132+
133+
carrier when is_map(carrier) and map_size(carrier) > 0 ->
134+
# Check if we already have an attached context with a valid span
135+
# If so, don't overwrite it (this happens during static renders)
136+
current_span_ctx = :otel_tracer.current_span_ctx()
137+
138+
unless has_valid_span?(current_span_ctx) do
139+
# Extract and attach the trace context
140+
ctx = :otel_ctx.get_current()
141+
142+
new_ctx =
143+
Sentry.OpenTelemetry.Propagator.extract(
144+
ctx,
145+
carrier,
146+
&map_keys/1,
147+
&map_getter/2,
148+
[]
149+
)
150+
151+
_old_ctx = :otel_ctx.attach(new_ctx)
152+
end
153+
154+
:ok
155+
156+
_ ->
157+
:ok
158+
end
159+
end
160+
161+
# Try to get the carrier from socket private assigns
162+
defp get_context_carrier(socket) do
163+
session_key = Sentry.Plug.LiveViewContext.session_key()
164+
165+
# The session can be in different places depending on the connection type:
166+
# 1. WebSocket: socket.private.connect_info.session (map)
167+
# 2. Static render (test): socket.private.connect_info is a %Plug.Conn{}
168+
case socket do
169+
# WebSocket connection has session as a map
170+
%{private: %{connect_info: %{session: session}}} when is_map(session) ->
171+
Map.get(session, session_key)
172+
173+
# Static render (Phoenix.LiveViewTest) has connect_info as Plug.Conn
174+
%{private: %{connect_info: %Plug.Conn{private: %{plug_session: session}}}}
175+
when is_map(session) ->
176+
Map.get(session, session_key)
177+
178+
_ ->
179+
nil
180+
end
181+
end
182+
183+
# Check if span context has a valid (non-zero) trace ID
184+
defp has_valid_span?(:undefined), do: false
185+
186+
defp has_valid_span?(span_ctx) when is_tuple(span_ctx) do
187+
case span_ctx do
188+
{:span_ctx, trace_id, _span_id, _flags, _tracestate, _is_valid, _is_remote, _is_recording,
189+
_sdk}
190+
when trace_id != 0 ->
191+
true
192+
193+
_ ->
194+
false
195+
end
196+
end
197+
198+
defp has_valid_span?(_), do: false
199+
200+
defp map_keys(carrier), do: Map.keys(carrier)
201+
202+
defp map_getter(key, carrier) do
203+
case Map.fetch(carrier, key) do
204+
{:ok, value} -> value
205+
:error -> :undefined
206+
end
207+
end
208+
end
209+
end

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,64 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
1818
# This can be a no-op since we can postpone inserting the span into storage until on_end
1919
@impl :otel_span_processor
2020
def on_start(_ctx, otel_span, _config) do
21+
# Check if this is a LiveView span during static render
22+
# If so, mark it so we can filter it out in on_end
23+
if Sentry.OpenTelemetry.LiveViewPropagator.static_render?() do
24+
# Set an attribute on the span to mark it as from static render
25+
:otel_span.set_attribute(otel_span, :"sentry.liveview.static_render", true)
26+
end
27+
2128
otel_span
2229
end
2330

2431
@impl :otel_span_processor
2532
def on_end(otel_span, _config) do
2633
span_record = SpanRecord.new(otel_span)
2734

35+
# Skip LiveView spans from static render - they're redundant since:
36+
# 1. The HTTP span already covers the static render phase
37+
# 2. The same LiveView callbacks run again over WebSocket (the meaningful ones)
38+
if static_render_liveview_span?(span_record) do
39+
# Don't store or process this span
40+
true
41+
else
42+
process_span(span_record)
43+
end
44+
end
45+
46+
defp process_span(span_record) do
2847
SpanStorage.store_span(span_record)
2948

30-
# Check if this is a root span (no parent) or a transaction root (HTTP server request span)
31-
# HTTP server request spans should be treated as transaction roots even when they have
32-
# an external parent span ID (from distributed tracing)
49+
# Check if this is a root span (no parent) or a transaction root
50+
#
51+
# A span should be a transaction root if:
52+
# 1. It has no parent (true root span)
53+
# 2. OR it's a server span with only a REMOTE parent (distributed tracing)
54+
#
55+
# A span should NOT be a transaction root if:
56+
# - It has a LOCAL parent (parent span exists in our SpanStorage)
57+
#
58+
# This prevents LiveView spans from becoming separate transactions when
59+
# they occur within an HTTP request - they should be child spans of the
60+
# HTTP server span instead.
3361
is_transaction_root =
34-
span_record.parent_span_id == nil or is_http_server_request_span?(span_record)
62+
cond do
63+
# No parent = definitely a root
64+
span_record.parent_span_id == nil ->
65+
true
66+
67+
# Has a parent - check if it's local or remote
68+
true ->
69+
has_local_parent = has_local_parent_span?(span_record.parent_span_id)
70+
71+
if has_local_parent do
72+
# Parent exists locally - this is a child span, not a transaction root
73+
false
74+
else
75+
# Parent is remote (distributed tracing) - treat server spans as transaction roots
76+
is_server_span?(span_record)
77+
end
78+
end
3579

3680
if is_transaction_root do
3781
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
@@ -71,18 +115,42 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
71115
end
72116
end
73117

118+
# Check if this is a LiveView span from static render that should be filtered out
119+
defp static_render_liveview_span?(span_record) do
120+
# Check for the attribute we set in on_start
121+
Map.get(span_record.attributes, :"sentry.liveview.static_render", false) == true
122+
end
123+
74124
@impl :otel_span_processor
75125
def force_flush(_config) do
76126
:ok
77127
end
78128

79-
# Helper function to detect if a span represents an HTTP server request
80-
# that should be treated as a transaction root for distributed tracing
81-
defp is_http_server_request_span?(%{kind: kind, attributes: attributes}) do
82-
kind == :server and
83-
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
129+
# Checks if a parent span exists in our local SpanStorage
130+
# This helps distinguish between:
131+
# - Local parents: span exists in storage (same service)
132+
# - Remote parents: span doesn't exist in storage (distributed tracing from another service)
133+
defp has_local_parent_span?(parent_span_id) do
134+
SpanStorage.span_exists?(parent_span_id)
84135
end
85136

137+
# Helper function to detect if a span is a server span that should be
138+
# treated as a transaction root. This includes:
139+
# - HTTP server request spans (have http.request.method attribute)
140+
# - LiveView spans from OpentelemetryPhoenix (have kind: :server and origin: "opentelemetry_phoenix")
141+
# But NOT internal spans created with Tracer.with_span (which have kind: :internal)
142+
defp is_server_span?(%{kind: :server, attributes: attributes} = span_record) do
143+
# Check if it's an HTTP server request span (has http.request.method)
144+
has_http_method = Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
145+
146+
# Check if it's a LiveView span from OpentelemetryPhoenix
147+
is_liveview_span = span_record.origin == "opentelemetry_phoenix"
148+
149+
has_http_method or is_liveview_span
150+
end
151+
152+
defp is_server_span?(_), do: false
153+
86154
defp build_transaction(root_span_record, child_span_records) do
87155
root_span = build_span(root_span_record)
88156
child_spans = Enum.map(child_span_records, &build_span(&1))
@@ -135,12 +203,27 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
135203
client_address =
136204
Map.get(span_record.attributes, to_string(ClientAttributes.client_address()))
137205

138-
url_path = Map.get(span_record.attributes, to_string(URLAttributes.url_path()))
206+
# Try multiple attributes for the URL path
207+
url_path =
208+
Map.get(span_record.attributes, to_string(URLAttributes.url_path())) ||
209+
Map.get(span_record.attributes, "url.full") ||
210+
Map.get(span_record.attributes, "http.target") ||
211+
Map.get(span_record.attributes, "http.route") ||
212+
span_record.name
139213

214+
# Build description with method and path
140215
description =
141-
to_string(http_request_method) <>
142-
((client_address && " from #{client_address}") || "") <>
143-
((url_path && " #{url_path}") || "")
216+
case url_path do
217+
nil -> to_string(http_request_method)
218+
path -> "#{http_request_method} #{path}"
219+
end
220+
221+
description =
222+
if client_address do
223+
"#{description} from #{client_address}"
224+
else
225+
description
226+
end
144227

145228
{op, description}
146229
end

lib/sentry/opentelemetry/span_storage.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
6161
end
6262
end
6363

64+
@spec span_exists?(String.t(), keyword()) :: boolean()
65+
def span_exists?(span_id, opts \\ []) do
66+
table_name = Keyword.get(opts, :table_name, default_table_name())
67+
68+
# Check root spans first
69+
case :ets.lookup(table_name, {:root_span, span_id}) do
70+
[_] ->
71+
true
72+
73+
[] ->
74+
# Check child spans - scan for any child span with this span_id
75+
# This is O(n) but necessary when the span has a remote parent
76+
case :ets.match_object(table_name, {{:child_span, :_, span_id}, :_, :_}) do
77+
[_ | _] -> true
78+
[] -> false
79+
end
80+
end
81+
end
82+
6483
@spec get_child_spans(String.t(), keyword()) :: [SpanRecord.t()]
6584
def get_child_spans(parent_span_id, opts \\ []) do
6685
table_name = Keyword.get(opts, :table_name, default_table_name())

0 commit comments

Comments
 (0)