Skip to content

Commit 8a30ebd

Browse files
committed
wip - support for DT in LV
1 parent 4bbcc0a commit 8a30ebd

File tree

12 files changed

+327
-124
lines changed

12 files changed

+327
-124
lines changed

lib/sentry.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ defmodule Sentry do
1414
1515
* Automatically for Plug/Phoenix applications — see the
1616
[*Setup with Plug and Phoenix* guide](setup-with-plug-and-phoenix.html), and the
17-
`Sentry.PlugCapture` and `Sentry.PlugContext` modules.
17+
`Sentry.PlugCapture`, `Sentry.PlugContext`, `Sentry.Plug.LiveViewContext`, and
18+
`Sentry.Phoenix.LiveViewTracing`.
1819
1920
* Through integrations for various ecosystem tools, like [Oban](oban-integration.html)
2021
or [Quantum](quantum-integration.html).

lib/sentry/opentelemetry/span_processor.ex

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
3131
# HTTP server request spans should be treated as transaction roots even when they have
3232
# an external parent span ID (from distributed tracing)
3333
is_transaction_root =
34-
span_record.parent_span_id == nil or is_http_server_request_span?(span_record)
34+
span_record.parent_span_id == nil or
35+
is_http_server_request_span?(span_record) or
36+
is_live_view_server_span?(span_record)
3537

3638
if is_transaction_root do
37-
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
39+
child_span_records =
40+
span_record.span_id
41+
|> SpanStorage.get_child_spans()
42+
|> maybe_add_remote_children(span_record)
43+
3844
transaction = build_transaction(span_record, child_span_records)
3945

4046
result =
@@ -83,6 +89,68 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
8389
Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method()))
8490
end
8591

92+
defp is_live_view_server_span?(%{kind: :server, origin: origin, name: name})
93+
when origin in ["opentelemetry_phoenix", :opentelemetry_phoenix] do
94+
String.ends_with?(name, ".mount") or
95+
String.contains?(name, ".handle_params") or
96+
String.contains?(name, ".handle_event")
97+
end
98+
99+
defp is_live_view_server_span?(_span_record), do: false
100+
101+
defp maybe_add_remote_children(child_span_records, %{parent_span_id: nil}) do
102+
child_span_records
103+
end
104+
105+
defp maybe_add_remote_children(child_span_records, span_record) do
106+
if is_live_view_server_span?(span_record) do
107+
existing_ids = MapSet.new(child_span_records, & &1.span_id)
108+
109+
adopted_children =
110+
span_record.parent_span_id
111+
|> SpanStorage.get_child_spans()
112+
|> Enum.filter(&eligible_for_adoption?(&1, span_record, existing_ids))
113+
|> Enum.map(&%{&1 | parent_span_id: span_record.span_id})
114+
115+
Enum.each(adopted_children, fn child ->
116+
:ok = SpanStorage.remove_child_span(span_record.parent_span_id, child.span_id)
117+
end)
118+
119+
child_span_records ++ adopted_children
120+
else
121+
child_span_records
122+
end
123+
end
124+
125+
defp eligible_for_adoption?(child, span_record, existing_ids) do
126+
not MapSet.member?(existing_ids, child.span_id) and
127+
child.parent_span_id == span_record.parent_span_id and
128+
child.trace_id == span_record.trace_id and
129+
child.kind != :server and
130+
occurs_within_span?(child, span_record)
131+
end
132+
133+
defp occurs_within_span?(child, parent) do
134+
with {:ok, parent_start} <- parse_datetime(parent.start_time),
135+
{:ok, parent_end} <- parse_datetime(parent.end_time),
136+
{:ok, child_start} <- parse_datetime(child.start_time),
137+
{:ok, child_end} <- parse_datetime(child.end_time) do
138+
DateTime.compare(child_start, parent_start) != :lt and
139+
DateTime.compare(child_end, parent_end) != :gt
140+
else
141+
_ -> true
142+
end
143+
end
144+
145+
defp parse_datetime(nil), do: :error
146+
147+
defp parse_datetime(timestamp) do
148+
case DateTime.from_iso8601(timestamp) do
149+
{:ok, datetime, _offset} -> {:ok, datetime}
150+
{:error, _} -> :error
151+
end
152+
end
153+
86154
defp build_transaction(root_span_record, child_span_records) do
87155
root_span = build_span(root_span_record)
88156
child_spans = Enum.map(child_span_records, &build_span(&1))
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
if Code.ensure_loaded?(Phoenix.LiveView) and
2+
Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
3+
defmodule Sentry.Phoenix.LiveViewTracing do
4+
@moduledoc """
5+
LiveView hook that attaches the propagated OpenTelemetry context saved by `Sentry.Plug.LiveViewContext`.
6+
7+
Configure your router with `on_mount {Sentry.Phoenix.LiveViewTracing, :attach}` so that the LiveView
8+
process can deserialize the carrier written to the session, attach it to the process, and inherit the
9+
incoming trace ID for spans emitted by `opentelemetry_phoenix`.
10+
11+
If the session key is missing (for example, when a LiveView spawns another LiveView), the hook falls
12+
back to `OpentelemetryProcessPropagator.fetch_parent_ctx/0` when the dependency is available.
13+
"""
14+
15+
alias OpenTelemetry.Ctx
16+
alias Sentry.OpenTelemetry.Propagator
17+
18+
@session_key "__sentry_live_view_context__"
19+
@context_token_key :sentry_live_view_tracing_token
20+
21+
@doc """
22+
Attach the propagated context to a LiveView process.
23+
"""
24+
@spec on_mount(atom(), map(), map(), Phoenix.LiveView.Socket.t()) ::
25+
{:cont, Phoenix.LiveView.Socket.t()}
26+
def on_mount(:attach, _params, session, socket) do
27+
{:cont, maybe_attach_context(socket, session)}
28+
end
29+
30+
defp maybe_attach_context(socket, session) do
31+
case Map.get(session, @session_key) do
32+
carrier when is_map(carrier) and carrier != %{} ->
33+
attach_context(socket, extract_context(carrier))
34+
35+
_ ->
36+
attach_context(socket, fetch_parent_context())
37+
end
38+
end
39+
40+
defp extract_context(carrier) do
41+
ctx = Ctx.get_current()
42+
keys_fun = fn _ -> Map.keys(carrier) end
43+
getter = fn key, _ -> Map.get(carrier, key, :undefined) end
44+
45+
Propagator.extract(ctx, carrier, keys_fun, getter, [])
46+
end
47+
48+
defp fetch_parent_context do
49+
module = :OpentelemetryProcessPropagator
50+
51+
if Code.ensure_loaded?(module) do
52+
apply(module, :fetch_parent_ctx, [])
53+
else
54+
:undefined
55+
end
56+
end
57+
58+
defp attach_context(socket, ctx) when ctx in [:undefined, nil] do
59+
socket
60+
end
61+
62+
defp attach_context(socket, ctx) do
63+
token = Ctx.attach(ctx)
64+
%{socket | private: Map.put(socket.private, @context_token_key, token)}
65+
end
66+
end
67+
end
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
if Code.ensure_loaded?(Plug) and Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
2+
defmodule Sentry.Plug.LiveViewContext do
3+
@moduledoc """
4+
Plug that captures the current OpenTelemetry context and embeds it into the LiveView session.
5+
6+
When placed before your LiveView routes, it serializes the currently attached trace (`sentry-trace`
7+
and `baggage`) via `Sentry.OpenTelemetry.Propagator`. The serialized carrier is stored under
8+
`"__sentry_live_view_context__"` in the session so the LiveView process can pick it up, and a
9+
companion cleanup plug removes the key after the response has been committed.
10+
"""
11+
12+
@behaviour Plug
13+
14+
alias OpenTelemetry.Ctx
15+
alias OpenTelemetry.Tracer
16+
alias Sentry.OpenTelemetry.Propagator
17+
18+
@session_key "__sentry_live_view_context__"
19+
@sentry_trace_header "sentry-trace"
20+
@ctx_token_key :sentry_live_view_ctx_token
21+
22+
@impl Plug
23+
def init(opts), do: opts
24+
25+
@impl Plug
26+
def call(conn, _opts) do
27+
{conn, carrier} = ensure_trace_carrier(conn)
28+
store_session(conn, carrier)
29+
end
30+
31+
defp store_session(conn, carrier) do
32+
if Map.has_key?(carrier, @sentry_trace_header) do
33+
conn
34+
|> Plug.Conn.fetch_session()
35+
|> Plug.Conn.put_session(@session_key, carrier)
36+
else
37+
conn
38+
end
39+
end
40+
41+
defp ensure_trace_carrier(conn) do
42+
ctx_carrier = build_carrier_from_ctx()
43+
44+
if Map.has_key?(ctx_carrier, @sentry_trace_header) do
45+
{conn, ctx_carrier}
46+
else
47+
header_carrier = build_carrier_from_headers(conn)
48+
49+
if Map.has_key?(header_carrier, @sentry_trace_header) do
50+
attach_conn = attach_from_headers(conn, header_carrier)
51+
{attach_conn, header_carrier}
52+
else
53+
{conn, header_carrier}
54+
end
55+
end
56+
end
57+
58+
defp build_carrier_from_ctx do
59+
ctx = Ctx.get_current()
60+
setter = fn key, value, acc -> Map.put(acc, key, value) end
61+
Propagator.inject(ctx, %{}, setter, [])
62+
end
63+
64+
defp build_carrier_from_headers(conn) do
65+
Enum.reduce([@sentry_trace_header, "baggage"], %{}, fn header, acc ->
66+
case Plug.Conn.get_req_header(conn, header) do
67+
[value | _] -> Map.put(acc, header, value)
68+
_ -> acc
69+
end
70+
end)
71+
end
72+
73+
defp attach_from_headers(conn, carrier) do
74+
ctx = maybe_extract(carrier)
75+
76+
case ctx do
77+
nil ->
78+
conn
79+
80+
_ ->
81+
span_ctx = Tracer.current_span_ctx(ctx)
82+
83+
if span_ctx == :undefined do
84+
conn
85+
else
86+
token = Ctx.attach(ctx)
87+
register_detach(conn, token)
88+
end
89+
end
90+
end
91+
92+
defp maybe_extract(carrier) do
93+
ctx = Ctx.get_current()
94+
getter = fn key, _ -> Map.get(carrier, key, :undefined) end
95+
Propagator.extract(ctx, carrier, fn _ -> Map.keys(carrier) end, getter, [])
96+
rescue
97+
_ -> nil
98+
end
99+
100+
defp register_detach(conn, token) do
101+
conn
102+
|> Plug.Conn.put_private(@ctx_token_key, token)
103+
|> Plug.Conn.register_before_send(fn conn ->
104+
if token = conn.private[@ctx_token_key] do
105+
Ctx.detach(token)
106+
end
107+
108+
conn
109+
end)
110+
end
111+
112+
@doc false
113+
def delete_session_key(conn) do
114+
Plug.Conn.delete_session(conn, @session_key)
115+
end
116+
end
117+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
if Code.ensure_loaded?(Plug) do
2+
defmodule Sentry.Plug.LiveViewContextCleanup do
3+
@moduledoc false
4+
5+
@behaviour Plug
6+
7+
alias Sentry.Plug.LiveViewContext
8+
9+
@impl Plug
10+
def init(opts), do: opts
11+
12+
@impl Plug
13+
def call(conn, _opts) do
14+
Plug.Conn.register_before_send(conn, &LiveViewContext.delete_session_key/1)
15+
end
16+
end
17+
end

mix.exs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ defmodule Sentry.Mixfile do
5050
"Upgrade Guides": [~r{^pages/upgrade}]
5151
],
5252
groups_for_modules: [
53-
"Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext, Sentry.LiveViewHook],
53+
"Plug and Phoenix": [
54+
Sentry.PlugCapture,
55+
Sentry.PlugContext,
56+
Sentry.Plug.LiveViewContext,
57+
Sentry.LiveViewHook,
58+
Sentry.Phoenix.LiveViewTracing
59+
],
5460
Loggers: [Sentry.LoggerBackend, Sentry.LoggerHandler],
5561
"Data Structures": [Sentry.Attachment, Sentry.CheckIn, Sentry.ClientReport],
5662
HTTP: [Sentry.HTTPClient, Sentry.HackneyClient],

0 commit comments

Comments
 (0)