Skip to content

Commit 047f1a2

Browse files
committed
wip - initial work on Transaction support in Envelope
1 parent 9ecee5d commit 047f1a2

14 files changed

+652
-7
lines changed

config/config.exs

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ if config_env() == :test do
1313
test_mode: true
1414

1515
config :logger, backends: []
16+
17+
config :opentelemetry, span_processor: {Sentry.Telemetry.SpanProcessor, []}
1618
end
1719

1820
config :phoenix, :json_library, Jason

lib/sentry.ex

+16
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,22 @@ defmodule Sentry do
361361
end
362362
end
363363

364+
def send_transaction(transaction, opts \\ []) do
365+
# TODO: remove on v11.0.0, :included_environments was deprecated in 10.0.0.
366+
included_envs = Config.included_environments()
367+
368+
cond do
369+
Config.test_mode?() ->
370+
Client.send_transaction(transaction, opts)
371+
372+
included_envs == :all or to_string(Config.environment_name()) in included_envs ->
373+
Client.send_transaction(transaction, opts)
374+
375+
true ->
376+
:ignored
377+
end
378+
end
379+
364380
@doc """
365381
Captures a check-in built with the given `options`.
366382

lib/sentry/application.ex

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ defmodule Sentry.Application do
2626
{Registry, keys: :unique, name: Sentry.Transport.SenderRegistry},
2727
Sentry.Sources,
2828
Sentry.Dedupe,
29+
Sentry.Telemetry.SpanProcessor.SpanStorage,
2930
{Sentry.Integrations.CheckInIDMappings,
3031
[
3132
max_expected_check_in_time:

lib/sentry/client.ex

+62-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ defmodule Sentry.Client do
1515
Interfaces,
1616
LoggerUtils,
1717
Transport,
18-
Options
18+
Options,
19+
Transaction
1920
}
2021

2122
require Logger
@@ -92,6 +93,29 @@ defmodule Sentry.Client do
9293
end
9394
end
9495

96+
def send_transaction(%Transaction{} = transaction, opts \\ []) do
97+
# opts = validate_options!(opts)
98+
99+
result_type = Keyword.get_lazy(opts, :result, &Config.send_result/0)
100+
client = Keyword.get_lazy(opts, :client, &Config.client/0)
101+
102+
request_retries =
103+
Keyword.get_lazy(opts, :request_retries, fn ->
104+
Application.get_env(:sentry, :request_retries, Transport.default_retries())
105+
end)
106+
107+
case encode_and_send(transaction, result_type, client, request_retries) do
108+
{:ok, id} ->
109+
{:ok, id}
110+
111+
{:error, {status, headers, body}} ->
112+
{:error, ClientError.server_error(status, headers, body)}
113+
114+
{:error, reason} ->
115+
{:error, ClientError.new(reason)}
116+
end
117+
end
118+
95119
defp sample_event(sample_rate) do
96120
cond do
97121
sample_rate == 1 -> :ok
@@ -191,6 +215,43 @@ defmodule Sentry.Client do
191215
end
192216
end
193217

218+
defp encode_and_send(
219+
%Transaction{} = transaction,
220+
_result_type = :sync,
221+
client,
222+
request_retries
223+
) do
224+
case Sentry.Test.maybe_collect(transaction) do
225+
:collected ->
226+
{:ok, ""}
227+
228+
:not_collecting ->
229+
send_result =
230+
transaction
231+
|> Envelope.from_transaction()
232+
|> Transport.encode_and_post_envelope(client, request_retries)
233+
234+
_ = maybe_log_send_result(send_result, transaction)
235+
send_result
236+
end
237+
end
238+
239+
defp encode_and_send(
240+
%Transaction{} = transaction,
241+
_result_type = :none,
242+
client,
243+
_request_retries
244+
) do
245+
case Sentry.Test.maybe_collect(transaction) do
246+
:collected ->
247+
{:ok, ""}
248+
249+
:not_collecting ->
250+
:ok = Transport.Sender.send_async(client, transaction)
251+
{:ok, ""}
252+
end
253+
end
254+
194255
@spec render_event(Event.t()) :: map()
195256
def render_event(%Event{} = event) do
196257
json_library = Config.json_library()

lib/sentry/envelope.ex

+23-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule Sentry.Envelope do
22
@moduledoc false
33
# https://develop.sentry.dev/sdk/envelopes/
44

5-
alias Sentry.{Attachment, CheckIn, Config, Event, UUID}
5+
alias Sentry.{Attachment, CheckIn, Config, Event, UUID, Transaction}
66

77
@type t() :: %__MODULE__{
88
event_id: UUID.t(),
@@ -34,6 +34,17 @@ defmodule Sentry.Envelope do
3434
}
3535
end
3636

37+
@doc """
38+
Creates a new envelope containing a transaction with spans.
39+
"""
40+
@spec from_transaction(Sentry.Transaction.t()) :: t()
41+
def from_transaction(%Transaction{} = transaction) do
42+
%__MODULE__{
43+
event_id: transaction.event_id,
44+
items: [transaction]
45+
}
46+
end
47+
3748
@doc """
3849
Encodes the envelope into its binary representation.
3950
@@ -92,4 +103,15 @@ defmodule Sentry.Envelope do
92103
throw(error)
93104
end
94105
end
106+
107+
defp item_to_binary(json_library, %Transaction{} = transaction) do
108+
case transaction |> Transaction.to_map() |> json_library.encode() do
109+
{:ok, encoded_transaction} ->
110+
header = ~s({"type": "transaction", "length": #{byte_size(encoded_transaction)}})
111+
[header, ?\n, encoded_transaction, ?\n]
112+
113+
{:error, _reason} = error ->
114+
throw(error)
115+
end
116+
end
95117
end
+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
defmodule Sentry.Telemetry.SpanProcessor do
2+
@behaviour :otel_span_processor
3+
4+
require Record
5+
6+
@fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl")
7+
Record.defrecordp(:span, @fields)
8+
9+
alias Sentry.{Span, Transaction}
10+
11+
defmodule SpanStorage do
12+
use GenServer
13+
14+
def start_link(_opts) do
15+
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
16+
end
17+
18+
def init(_) do
19+
{:ok, %{root_spans: %{}, child_spans: %{}}}
20+
end
21+
22+
def store_span(span_data) do
23+
GenServer.call(__MODULE__, {:store_span, span_data})
24+
end
25+
26+
def get_root_span(span_id) do
27+
GenServer.call(__MODULE__, {:get_root_span, span_id})
28+
end
29+
30+
def get_child_spans(parent_span_id) do
31+
GenServer.call(__MODULE__, {:get_child_spans, parent_span_id})
32+
end
33+
34+
def update_span(span_data) do
35+
GenServer.call(__MODULE__, {:update_span, span_data})
36+
end
37+
38+
def handle_call({:store_span, span_data}, _from, state) do
39+
if span_data[:parent_span_id] == :undefined do
40+
new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data)
41+
{:reply, :ok, new_state}
42+
else
43+
new_state =
44+
update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans ->
45+
(spans || []) ++ [span_data]
46+
end)
47+
48+
{:reply, :ok, new_state}
49+
end
50+
end
51+
52+
def handle_call({:get_root_span, span_id}, _from, state) do
53+
{:reply, state.root_spans[span_id], state}
54+
end
55+
56+
def handle_call({:get_child_spans, parent_span_id}, _from, state) do
57+
{:reply, state.child_spans[parent_span_id] || [], state}
58+
end
59+
60+
def handle_call({:update_span, span_data}, _from, state) do
61+
if span_data[:parent_span_id] == :undefined do
62+
new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data)
63+
{:reply, :ok, new_state}
64+
else
65+
new_state =
66+
update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans ->
67+
Enum.map(spans || [], fn span ->
68+
if span[:span_id] == span_data[:span_id], do: span_data, else: span
69+
end)
70+
end)
71+
72+
{:reply, :ok, new_state}
73+
end
74+
end
75+
end
76+
77+
@impl true
78+
def on_start(_ctx, otel_span, _config) do
79+
span_record = span(otel_span)
80+
81+
SpanStorage.store_span(span_record)
82+
83+
otel_span
84+
end
85+
86+
@impl true
87+
def on_end(otel_span, _config) do
88+
span_record = span(otel_span)
89+
90+
SpanStorage.update_span(span_record)
91+
92+
if span_record[:parent_span_id] == :undefined do
93+
root_span = SpanStorage.get_root_span(span_record[:span_id])
94+
child_spans = SpanStorage.get_child_spans(span_record[:span_id])
95+
96+
transaction = transaction_from_root_span(root_span, child_spans)
97+
Sentry.send_transaction(transaction)
98+
end
99+
100+
:ok
101+
end
102+
103+
@impl true
104+
def force_flush(_config) do
105+
:ok
106+
end
107+
108+
defp transaction_from_root_span(root_span, child_spans) do
109+
trace_id = cast_trace_id(root_span[:trace_id])
110+
111+
Transaction.new(%{
112+
transaction: root_span[:name],
113+
start_timestamp: cast_timestamp(root_span[:start_time]),
114+
timestamp: cast_timestamp(root_span[:end_time]),
115+
transaction_info: %{
116+
source: "route"
117+
},
118+
contexts: %{
119+
trace: %{
120+
op: root_span[:name],
121+
trace_id: trace_id,
122+
span_id: to_string(root_span[:span_id]),
123+
parent_span_id: nil
124+
}
125+
},
126+
spans: Enum.map([root_span | child_spans], &span_from_record(&1, trace_id))
127+
})
128+
end
129+
130+
defp span_from_record(span_record, trace_id) do
131+
%Span{
132+
op: span_record[:name] || "unknown",
133+
start_timestamp: cast_timestamp(span_record[:start_time]),
134+
timestamp: cast_timestamp(span_record[:end_time]),
135+
trace_id: trace_id,
136+
span_id: to_string(span_record[:span_id] || ""),
137+
parent_span_id: cast_parent_span_id(span_record[:parent_span_id])
138+
}
139+
end
140+
141+
defp cast_trace_id(trace_id) do
142+
:crypto.hash(:md5, to_string(trace_id))
143+
|> Base.encode16(case: :lower)
144+
end
145+
146+
defp cast_parent_span_id(:undefined), do: nil
147+
defp cast_parent_span_id(nil), do: nil
148+
defp cast_parent_span_id(parent_span_id), do: to_string(parent_span_id)
149+
150+
defp cast_timestamp(:undefined), do: nil
151+
defp cast_timestamp(nil), do: nil
152+
153+
defp cast_timestamp(timestamp) do
154+
nano_timestamp = :opentelemetry.timestamp_to_nano(timestamp)
155+
{:ok, datetime} = DateTime.from_unix(div(nano_timestamp, 1_000_000), :millisecond)
156+
157+
DateTime.to_iso8601(datetime)
158+
end
159+
end

0 commit comments

Comments
 (0)