|
| 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