Skip to content

Commit b77b913

Browse files
committed
feat(tracing): support for nested LiveView spans
1 parent 8e02968 commit b77b913

11 files changed

Lines changed: 1212 additions & 21 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
1616
alias Sentry.{Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord}
1717
alias Sentry.Interfaces.Span
1818

19-
# Extract span record fields to access parent_span_id in on_start
20-
@span_fields Record.extract(:span, from_lib: "opentelemetry/include/otel_span.hrl")
21-
Record.defrecordp(:span, @span_fields)
22-
2319
@impl :otel_span_processor
2420
def on_start(_ctx, otel_span, _config) do
2521
span_record = SpanRecord.new(otel_span)
@@ -32,25 +28,71 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
3228
span_record = SpanRecord.new(otel_span)
3329
SpanStorage.update_span(span_record)
3430

35-
if is_transaction_root?(span_record) do
31+
process_span(span_record)
32+
end
33+
34+
@impl :otel_span_processor
35+
def force_flush(_config) do
36+
:ok
37+
end
38+
39+
defp process_span(span_record) do
40+
# Check if this is a root span (no parent) or a transaction root
41+
#
42+
# A span should be a transaction root if:
43+
# 1. It has no parent (true root span)
44+
# 2. OR it's a server span with only a REMOTE parent (distributed tracing)
45+
#
46+
# A span should NOT be a transaction root if:
47+
# - It has a LOCAL parent (parent span exists in our SpanStorage)
48+
#
49+
# Note: LiveView spans during static render are filtered earlier by
50+
# skip_static_render_liveview_span?/1, so we don't need to handle them here.
51+
is_transaction_root =
52+
cond do
53+
# No parent = definitely a root
54+
span_record.parent_span_id == nil ->
55+
true
56+
57+
# Has a parent - check if it's local or remote
58+
true ->
59+
has_local_parent = has_local_parent_span?(span_record.parent_span_id)
60+
61+
if has_local_parent do
62+
# Parent exists locally - this is a child span, not a transaction root
63+
false
64+
else
65+
# Parent is remote (distributed tracing) - treat server spans as transaction roots
66+
is_server_span?(span_record)
67+
end
68+
end
69+
70+
if is_transaction_root do
3671
build_and_send_transaction(span_record)
3772
else
3873
true
3974
end
4075
end
4176

42-
# Check if this is a root span (no parent) or a transaction root
43-
#
44-
# A span should be a transaction root if:
45-
#
46-
# 1. It has no parent (true root span)
47-
# 2. OR it's a span with a remote parent span
48-
#
49-
defp is_transaction_root?(span_record) do
50-
span_record.parent_span_id == nil or
51-
not SpanStorage.span_exists?(span_record.parent_span_id)
77+
defp has_local_parent_span?(parent_span_id) do
78+
SpanStorage.span_exists?(parent_span_id)
5279
end
5380

81+
# Check if it's an HTTP server request span or a LiveView span
82+
defp is_server_span?(%{kind: :server} = span_record) do
83+
is_http_server_span?(span_record) or is_liveview_span?(span_record)
84+
end
85+
86+
defp is_server_span?(_), do: false
87+
88+
defp is_http_server_span?(%{kind: :server, attributes: attributes}) do
89+
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
90+
end
91+
92+
# Check if span name matches LiveView lifecycle patterns
93+
defp is_liveview_span?(%{origin: "opentelemetry_phoenix"}), do: true
94+
defp is_liveview_span?(_), do: false
95+
5496
defp build_and_send_transaction(span_record) do
5597
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
5698
transaction = build_transaction(span_record, child_span_records)
@@ -77,11 +119,6 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
77119
result
78120
end
79121

80-
@impl :otel_span_processor
81-
def force_flush(_config) do
82-
:ok
83-
end
84-
85122
defp build_transaction(root_span_record, child_span_records) do
86123
root_span = build_span(root_span_record)
87124
child_spans = Enum.map(child_span_records, &build_span(&1))

lib/sentry/opentelemetry/span_storage.ex

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
5353
end
5454
end
5555

56+
@doc """
57+
Retrieves a span by its ID, regardless of whether it's a root or child span.
58+
Returns nil if the span is not found.
59+
"""
60+
@spec get_span(String.t(), keyword()) :: SpanRecord.t() | nil
61+
def get_span(span_id, opts \\ []) do
62+
table_name = Keyword.get(opts, :table_name, default_table_name())
63+
64+
case :ets.lookup(table_name, {:root_span, span_id}) do
65+
[{{:root_span, ^span_id}, span, _stored_at}] ->
66+
span
67+
68+
[] ->
69+
case :ets.match_object(table_name, {{:child_span, :_, span_id}, :_, :_}) do
70+
[{_key, span, _stored_at} | _] -> span
71+
[] -> nil
72+
end
73+
end
74+
end
75+
5676
@spec store_span(SpanRecord.t(), keyword()) :: true
5777
def store_span(span_data, opts \\ []) do
5878
table_name = Keyword.get(opts, :table_name, default_table_name())
@@ -137,6 +157,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
137157
:ok
138158
end
139159

160+
@spec remove_child_span(String.t(), String.t(), keyword()) :: :ok
161+
def remove_child_span(parent_span_id, span_id, opts \\ []) do
162+
table_name = Keyword.get(opts, :table_name, default_table_name())
163+
key = {:child_span, parent_span_id, span_id}
164+
165+
:ets.delete(table_name, key)
166+
167+
:ok
168+
end
169+
140170
defp schedule_cleanup(interval) do
141171
Process.send_after(self(), :cleanup_stale_spans, interval)
142172
end

0 commit comments

Comments
 (0)