Skip to content

Commit 17985cf

Browse files
authored
Add threads interface and fix messages with stacktraces (#668)
1 parent b0717f5 commit 17985cf

File tree

6 files changed

+132
-41
lines changed

6 files changed

+132
-41
lines changed

lib/sentry/client.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ defmodule Sentry.Client do
154154
|> update_if_present(:user, &sanitize_non_jsonable_values(&1, json_library))
155155
|> update_if_present(:tags, &sanitize_non_jsonable_values(&1, json_library))
156156
|> update_if_present(:exception, fn list -> Enum.map(list, &render_exception/1) end)
157+
|> update_if_present(:threads, fn list -> Enum.map(list, &render_thread/1) end)
157158
end
158159

159160
defp render_exception(%Interfaces.Exception{} = exception) do
@@ -164,6 +165,14 @@ defmodule Sentry.Client do
164165
end)
165166
end
166167

168+
defp render_thread(%Interfaces.Thread{} = thread) do
169+
thread
170+
|> Map.from_struct()
171+
|> update_if_present(:stacktrace, fn %Interfaces.Stacktrace{frames: frames} ->
172+
%{frames: Enum.map(frames, &Map.from_struct/1)}
173+
end)
174+
end
175+
167176
defp remove_nils(map) when is_map(map) do
168177
:maps.filter(fn _key, value -> not is_nil(value) end, map)
169178
end

lib/sentry/event.ex

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ defmodule Sentry.Event do
6767
message: String.t() | nil,
6868
request: Interfaces.Request.t() | nil,
6969
sdk: Interfaces.SDK.t() | nil,
70+
threads: [Interfaces.Thread.t()] | nil,
7071
user: Interfaces.user() | nil,
7172

7273
# Non-payload fields.
@@ -113,6 +114,7 @@ defmodule Sentry.Event do
113114
server_name: nil,
114115
tags: %{},
115116
transaction: nil,
117+
threads: nil,
116118
user: %{},
117119

118120
# "Culprit" is not documented anymore and we should move to transactions at some point.
@@ -255,7 +257,7 @@ defmodule Sentry.Event do
255257
stacktrace = Keyword.get(opts, :stacktrace)
256258
source = Keyword.get(opts, :event_source)
257259

258-
%__MODULE__{
260+
event = %__MODULE__{
259261
breadcrumbs: breadcrumbs,
260262
contexts: generate_contexts(),
261263
culprit: culprit_from_stacktrace(Keyword.get(opts, :stacktrace, [])),
@@ -277,23 +279,25 @@ defmodule Sentry.Event do
277279
timestamp: timestamp,
278280
user: user
279281
}
280-
end
281282

282-
defp coerce_exception(_exception = nil, _stacktrace = nil, _message) do
283-
nil
283+
# If we have a message *and* a stacktrace, but no exception, we need to store the stacktrace
284+
# information within a "thread" interface. This is how the Python SDK also does it. An issue
285+
# was opened in the sentry-elixir repo about this, but this is also a Sentry issue (if there
286+
# is an exception of type "message" with a stacktrace *and* a "message" attribute, it should
287+
# still show properly). This issue is now tracked in Sentry itself:
288+
# https://github.com/getsentry/sentry/issues/61239
289+
if message && stacktrace && is_nil(exception) do
290+
add_thread_with_stacktrace(event, stacktrace)
291+
else
292+
event
293+
end
284294
end
285295

286-
defp coerce_exception(_exception = nil, stacktrace_or_nil, message) when is_binary(message) do
287-
stacktrace =
288-
if is_list(stacktrace_or_nil) do
289-
%Interfaces.Stacktrace{frames: stacktrace_to_frames(stacktrace_or_nil)}
290-
end
291-
292-
%Interfaces.Exception{
293-
type: "message",
294-
value: message,
295-
stacktrace: stacktrace
296-
}
296+
# If we have a message with a stacktrace, but no exceptions, for now we store the stacktrace in
297+
# the "threads" interface and we don't fill in the "exception" interface altogether. This might
298+
# be eventually fixed in Sentry itself: https://github.com/getsentry/sentry/issues/61239
299+
defp coerce_exception(_exception = nil, _stacktrace_or_nil, message) when is_binary(message) do
300+
nil
297301
end
298302

299303
defp coerce_exception(exception, stacktrace_or_nil, _message) when is_exception(exception) do
@@ -328,6 +332,15 @@ defmodule Sentry.Event do
328332
end)
329333
end
330334

335+
defp add_thread_with_stacktrace(%__MODULE__{} = event, stacktrace) when is_list(stacktrace) do
336+
thread = %Interfaces.Thread{
337+
id: UUID.uuid4_hex(),
338+
stacktrace: %Interfaces.Stacktrace{frames: stacktrace_to_frames(stacktrace)}
339+
}
340+
341+
%__MODULE__{event | threads: [thread]}
342+
end
343+
331344
@doc """
332345
Transforms an exception to a Sentry event.
333346

lib/sentry/interfaces.ex

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,38 @@ defmodule Sentry.Interfaces do
190190

191191
defstruct [:type, :category, :message, :data, :level, :timestamp]
192192
end
193+
194+
defmodule Thread do
195+
@moduledoc """
196+
The struct for the **thread** interface.
197+
198+
See <https://develop.sentry.dev/sdk/event-payloads/threads>.
199+
"""
200+
201+
@moduledoc since: "10.1.0"
202+
203+
@typedoc since: "10.1.0"
204+
@type t() :: %__MODULE__{
205+
id: term(),
206+
crashed: boolean() | nil,
207+
current: boolean() | nil,
208+
main: boolean() | nil,
209+
name: String.t() | nil,
210+
state: term(),
211+
held_locks: [term()],
212+
stacktrace: Sentry.Interfaces.Stacktrace.t() | nil
213+
}
214+
215+
@enforce_keys [:id]
216+
defstruct [
217+
:id,
218+
:crashed,
219+
:current,
220+
:main,
221+
:name,
222+
:state,
223+
:held_locks,
224+
:stacktrace
225+
]
226+
end
193227
end

test/event_test.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,41 @@ defmodule Sentry.EventTest do
210210
} = Event.create_event(message: "Test message")
211211
end
212212

213+
test "fills in the message and threads interfaces when passing the :message option with :stacktrace" do
214+
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
215+
put_test_config(environment_name: "my_env")
216+
217+
assert %Event{
218+
breadcrumbs: [],
219+
environment: "my_env",
220+
exception: [],
221+
extra: %{},
222+
level: :error,
223+
message: "Test message",
224+
platform: :elixir,
225+
release: nil,
226+
request: %{},
227+
tags: %{},
228+
user: %{},
229+
contexts: %{os: %{name: _, version: _}, runtime: %{name: _, version: _}},
230+
threads: [%Interfaces.Thread{id: thread_id, stacktrace: thread_stacktrace}]
231+
} = Event.create_event(message: "Test message", stacktrace: stacktrace)
232+
233+
assert is_binary(thread_id) and byte_size(thread_id) > 0
234+
235+
assert [
236+
%Interfaces.Stacktrace.Frame{
237+
context_line: nil,
238+
in_app: false,
239+
lineno: _,
240+
post_context: [],
241+
pre_context: [],
242+
vars: %{}
243+
}
244+
| _rest
245+
] = thread_stacktrace.frames
246+
end
247+
213248
test "fills in private (:__...__) fields" do
214249
exception = %RuntimeError{message: "foo"}
215250

test/logger_backend_test.exs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,13 @@ defmodule Sentry.LoggerBackendTest do
3737
pid = start_supervised!(TestGenServer)
3838
TestGenServer.run_async(pid, fn _state -> throw("I am throwing") end)
3939
assert_receive {^ref, event}
40-
assert [exception] = event.exception
41-
assert exception.value =~ ~s<GenServer #{inspect(pid)} terminating\n>
42-
assert exception.value =~ ~s<** (stop) bad return value: "I am throwing"\n>
43-
assert exception.value =~ ~s<Last message: {:"$gen_cast",>
44-
assert exception.value =~ ~s<State: []>
45-
assert exception.stacktrace.frames == []
40+
assert [] = event.exception
41+
assert [thread] = event.threads
42+
assert event.message =~ ~s<GenServer #{inspect(pid)} terminating\n>
43+
assert event.message =~ ~s<** (stop) bad return value: "I am throwing"\n>
44+
assert event.message =~ ~s<Last message: {:"$gen_cast",>
45+
assert event.message =~ ~s<State: []>
46+
assert thread.stacktrace.frames == []
4647
end
4748

4849
test "abnormal GenServer exit is reported" do
@@ -51,12 +52,12 @@ defmodule Sentry.LoggerBackendTest do
5152
pid = start_supervised!(TestGenServer)
5253
TestGenServer.run_async(pid, fn state -> {:stop, :bad_exit, state} end)
5354
assert_receive {^ref, event}
54-
assert [exception] = event.exception
55-
assert exception.type == "message"
56-
assert exception.value =~ ~s<GenServer #{inspect(pid)} terminating\n>
57-
assert exception.value =~ ~s<** (stop) :bad_exit\n>
58-
assert exception.value =~ ~s<Last message: {:"$gen_cast",>
59-
assert exception.value =~ ~s<State: []>
55+
assert [] = event.exception
56+
assert [_thread] = event.threads
57+
assert event.message =~ ~s<GenServer #{inspect(pid)} terminating\n>
58+
assert event.message =~ ~s<** (stop) :bad_exit\n>
59+
assert event.message =~ ~s<Last message: {:"$gen_cast",>
60+
assert event.message =~ ~s<State: []>
6061
end
6162

6263
test "bad function call causing GenServer crash is reported" do
@@ -96,16 +97,15 @@ defmodule Sentry.LoggerBackendTest do
9697

9798
assert_receive {^ref, event}
9899

99-
assert [exception] = event.exception
100-
101-
assert exception.type == "message"
100+
assert [] = event.exception
101+
assert [thread] = event.threads
102102

103-
assert exception.value =~
103+
assert event.message =~
104104
"Task #{inspect(task_pid)} started from #{inspect(self())} terminating\n"
105105

106-
assert exception.value =~ "** (stop) exited in: GenServer.call("
107-
assert exception.value =~ "** (EXIT) time out"
108-
assert length(exception.stacktrace.frames) > 0
106+
assert event.message =~ "** (stop) exited in: GenServer.call("
107+
assert event.message =~ "** (EXIT) time out"
108+
assert length(thread.stacktrace.frames) > 0
109109
end
110110

111111
test "captures errors from spawn/0 in Plug app" do

test/sentry/logger_handler_test.exs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ defmodule Sentry.LoggerHandlerTest do
175175
assert event.message =~ "** (stop) :bad_exit"
176176

177177
if System.otp_release() >= "26" do
178-
assert hd(event.exception).type == "message"
178+
assert [] = event.exception
179+
assert [_thread] = event.threads
179180
end
180181
end
181182

@@ -273,13 +274,12 @@ defmodule Sentry.LoggerHandlerTest do
273274

274275
assert_receive {^ref, event}
275276

276-
assert [exception] = event.exception
277-
278-
assert exception.type == "message"
277+
assert [] = event.exception
278+
assert [thread] = event.threads
279279

280-
assert exception.value =~ "** (stop) exited in: GenServer.call("
281-
assert exception.value =~ "** (EXIT) time out"
282-
assert length(exception.stacktrace.frames) > 0
280+
assert event.message =~ "** (stop) exited in: GenServer.call("
281+
assert event.message =~ "** (EXIT) time out"
282+
assert length(thread.stacktrace.frames) > 0
283283
end
284284

285285
test "reports crashes on c:GenServer.init/1", %{sender_ref: ref} do

0 commit comments

Comments
 (0)