Skip to content

Commit 4bad58a

Browse files
committed
wip - add LiveView context propagation for distributed tracing
1 parent 4ed7589 commit 4bad58a

File tree

2 files changed

+417
-0
lines changed

2 files changed

+417
-0
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+
_ =
79+
:telemetry.attach_many(
80+
@handler_id,
81+
events,
82+
&__MODULE__.handle_event/4,
83+
%{}
84+
)
85+
86+
:ok
87+
end
88+
89+
@doc """
90+
Detaches the telemetry handlers. Mainly useful for testing.
91+
"""
92+
@spec teardown() :: :ok | {:error, :not_found}
93+
def teardown do
94+
:telemetry.detach(@handler_id)
95+
end
96+
97+
@doc false
98+
def handle_event(_event, _measurements, %{socket: socket} = _meta, _config) do
99+
# During static render (HTTP request phase), we mark spans to be filtered
100+
# since the HTTP span already covers this phase. Real LiveView interactions
101+
# happen over WebSocket.
102+
if static_render?(socket) do
103+
# Store in process dict that this is a static render - the span processor
104+
# can check this to filter out redundant LiveView spans
105+
Process.put(:sentry_lv_static_render, true)
106+
:ok
107+
else
108+
# WebSocket connection - clear the static render flag and propagate context
109+
Process.delete(:sentry_lv_static_render)
110+
propagate_context_if_needed(socket)
111+
end
112+
end
113+
114+
@doc """
115+
Returns true if the current process is handling a static LiveView render.
116+
Used by the span processor to filter redundant spans.
117+
"""
118+
def static_render? do
119+
Process.get(:sentry_lv_static_render, false)
120+
end
121+
122+
# Check if this is a static render (not connected via WebSocket)
123+
defp static_render?(socket) do
124+
# During static render, transport_pid is nil
125+
socket.transport_pid == nil
126+
end
127+
128+
defp propagate_context_if_needed(socket) do
129+
# Try to get trace context from socket's private assigns
130+
case get_context_carrier(socket) do
131+
nil ->
132+
:ok
133+
134+
carrier when is_map(carrier) and map_size(carrier) > 0 ->
135+
# Check if we already have an attached context with a valid span
136+
# If so, don't overwrite it (this happens during static renders)
137+
current_span_ctx = :otel_tracer.current_span_ctx()
138+
139+
_ =
140+
unless has_valid_span?(current_span_ctx) do
141+
# Extract and attach the trace context
142+
ctx = :otel_ctx.get_current()
143+
144+
new_ctx =
145+
Sentry.OpenTelemetry.Propagator.extract(
146+
ctx,
147+
carrier,
148+
&map_keys/1,
149+
&map_getter/2,
150+
[]
151+
)
152+
153+
:otel_ctx.attach(new_ctx)
154+
end
155+
156+
:ok
157+
158+
_ ->
159+
:ok
160+
end
161+
end
162+
163+
# Try to get the carrier from socket private assigns
164+
defp get_context_carrier(socket) do
165+
session_key = Sentry.Plug.LiveViewContext.session_key()
166+
167+
# The session can be in different places depending on the connection type:
168+
# 1. WebSocket: socket.private.connect_info.session (map)
169+
# 2. Static render (test): socket.private.connect_info is a %Plug.Conn{}
170+
case socket do
171+
# WebSocket connection has session as a map
172+
%{private: %{connect_info: %{session: session}}} when is_map(session) ->
173+
Map.get(session, session_key)
174+
175+
# Static render (Phoenix.LiveViewTest) has connect_info as Plug.Conn
176+
%{private: %{connect_info: %Plug.Conn{private: %{plug_session: session}}}}
177+
when is_map(session) ->
178+
Map.get(session, session_key)
179+
180+
_ ->
181+
nil
182+
end
183+
end
184+
185+
# Check if span context has a valid (non-zero) trace ID
186+
defp has_valid_span?(:undefined), do: false
187+
188+
defp has_valid_span?(span_ctx) when is_tuple(span_ctx) do
189+
case span_ctx do
190+
{:span_ctx, trace_id, _span_id, _flags, _tracestate, _is_valid, _is_remote, _is_recording,
191+
_sdk}
192+
when trace_id != 0 ->
193+
true
194+
195+
_other ->
196+
false
197+
end
198+
end
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

0 commit comments

Comments
 (0)