Skip to content

Commit 21d5d21

Browse files
committed
wip - add LiveView context propagation for distributed tracing
1 parent 3c486b6 commit 21d5d21

File tree

2 files changed

+419
-0
lines changed

2 files changed

+419
-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+
: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

0 commit comments

Comments
 (0)