|
| 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 | + case get_context_carrier(socket) do |
| 106 | + carrier when is_map(carrier) and map_size(carrier) > 0 -> |
| 107 | + current_span_ctx = :otel_tracer.current_span_ctx() |
| 108 | + |
| 109 | + # Extract and attach the trace context from the session if needed |
| 110 | + if has_valid_span?(current_span_ctx) do |
| 111 | + :ok |
| 112 | + else |
| 113 | + new_ctx = |
| 114 | + Sentry.OpenTelemetry.Propagator.extract( |
| 115 | + :otel_ctx.get_current(), |
| 116 | + carrier, |
| 117 | + &map_keys/1, |
| 118 | + &map_getter/2, |
| 119 | + [] |
| 120 | + ) |
| 121 | + |
| 122 | + :otel_ctx.attach(new_ctx) |
| 123 | + end |
| 124 | + |
| 125 | + nil -> |
| 126 | + :ok |
| 127 | + end |
| 128 | + end |
| 129 | + |
| 130 | + # Try to get the carrier from socket private assigns |
| 131 | + defp get_context_carrier(socket) do |
| 132 | + session_key = Sentry.Plug.LiveViewContext.session_key() |
| 133 | + |
| 134 | + case socket do |
| 135 | + %{private: %{connect_info: %{session: session}}} when is_map(session) -> |
| 136 | + Map.get(session, session_key) |
| 137 | + |
| 138 | + _ -> |
| 139 | + nil |
| 140 | + end |
| 141 | + end |
| 142 | + |
| 143 | + # Check if span context has a valid (non-zero) trace ID |
| 144 | + defp has_valid_span?(span_ctx(trace_id: trace_id)) when trace_id != 0, do: true |
| 145 | + defp has_valid_span?(_), do: false |
| 146 | + |
| 147 | + defp map_keys(carrier), do: Map.keys(carrier) |
| 148 | + |
| 149 | + defp map_getter(key, carrier) do |
| 150 | + case Map.fetch(carrier, key) do |
| 151 | + {:ok, value} -> value |
| 152 | + :error -> :undefined |
| 153 | + end |
| 154 | + end |
| 155 | + end |
| 156 | +end |
0 commit comments