Skip to content

Commit 086bb31

Browse files
authored
Add support for interpolating messages (#670)
Closes #664.
1 parent cb24caf commit 086bb31

File tree

9 files changed

+176
-34
lines changed

9 files changed

+176
-34
lines changed

lib/sentry.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,25 @@ defmodule Sentry do
261261
Reports a message to Sentry.
262262
263263
`opts` argument is passed as the second argument to `send_event/2`.
264+
265+
## Interpolation (since v10.1.0)
266+
267+
The `message` argument supports interpolation. You can pass a string with formatting
268+
markers as `%s`, ant then pass in the `:interpolation_parameters` option as a list
269+
of positional parameters to interpolate. For example:
270+
271+
Sentry.capture_message("Error with user %s", interpolation_parameters: ["John"])
272+
273+
This way, Sentry will group the messages based on the non-interpolated string, but it
274+
will show the interpolated string in the UI.
275+
276+
> #### Missing or Extra Parameters {: .neutral}
277+
>
278+
> If the message string has more `%s` markers than parameters, the extra `%s` markers
279+
> are included as is and the SDK doesn't raise any error. If you pass in more interpolation
280+
> parameters than `%s` markers, the extra parameters are ignored as well. This is because
281+
> the SDK doesn't want to be the cause of even more errors in your application when what
282+
> you're trying to do is report an error in the first place.
264283
"""
265284
@spec capture_message(String.t(), keyword()) :: send_result
266285
def capture_message(message, opts \\ []) when is_binary(message) do

lib/sentry/client.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,12 @@ defmodule Sentry.Client do
214214

215215
event
216216
|> Event.remove_non_payload_keys()
217-
|> update_if_present(:message, &String.slice(&1, 0, @max_message_length))
218217
|> update_if_present(:breadcrumbs, fn bcs -> Enum.map(bcs, &Map.from_struct/1) end)
219218
|> update_if_present(:sdk, &Map.from_struct/1)
219+
|> update_if_present(:message, fn message ->
220+
message = update_in(message.formatted, &String.slice(&1, 0, @max_message_length))
221+
Map.from_struct(message)
222+
end)
220223
|> update_if_present(:request, &(&1 |> Map.from_struct() |> remove_nils()))
221224
|> update_if_present(:extra, &sanitize_non_jsonable_values(&1, json_library))
222225
|> update_if_present(:user, &sanitize_non_jsonable_values(&1, json_library))

lib/sentry/event.ex

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ defmodule Sentry.Event do
6464
breadcrumbs: [Interfaces.Breadcrumb.t()],
6565
contexts: Interfaces.context(),
6666
exception: [Interfaces.Exception.t()],
67-
message: String.t() | nil,
67+
message: Interfaces.Message.t() | nil,
6868
request: Interfaces.Request.t() | nil,
6969
sdk: Interfaces.SDK.t() | nil,
7070
threads: [Interfaces.Thread.t()] | nil,
@@ -238,6 +238,14 @@ defmodule Sentry.Event do
238238
The source of the event. This fills in the `:source` field of the
239239
returned struct. This is not present by default.
240240
"""
241+
],
242+
interpolation_parameters: [
243+
type: {:list, :string},
244+
doc: """
245+
The parameters to use for message interpolation. This is only used if the
246+
`:message` option is present. This is not present by default. See
247+
`Sentry.capture_message/2`. *Available since v10.1.0*.
248+
"""
241249
]
242250
]
243251

@@ -335,7 +343,7 @@ defmodule Sentry.Event do
335343
extra: extra,
336344
fingerprint: Keyword.fetch!(opts, :fingerprint),
337345
level: Keyword.fetch!(opts, :level),
338-
message: message,
346+
message: message && build_message_interface(message, opts),
339347
modules: :persistent_term.get({:sentry, :loaded_applications}),
340348
original_exception: exception,
341349
release: Config.release(),
@@ -361,6 +369,38 @@ defmodule Sentry.Event do
361369
end
362370
end
363371

372+
defp build_message_interface(raw_message, opts) do
373+
if params = Keyword.get(opts, :interpolation_parameters) do
374+
%Interfaces.Message{
375+
formatted: interpolate(raw_message, params),
376+
message: raw_message,
377+
params: params
378+
}
379+
else
380+
%Interfaces.Message{formatted: raw_message}
381+
end
382+
end
383+
384+
# Made public for testing.
385+
@doc false
386+
def interpolate(message, params) do
387+
parts = Regex.split(~r{%s}, message, include_captures: true, trim: true)
388+
389+
{iodata, _params} =
390+
Enum.reduce(parts, {"", params}, fn
391+
"%s", {acc, [param | rest_params]} ->
392+
{[acc, to_string(param)], rest_params}
393+
394+
"%s", {acc, []} ->
395+
{[acc, "%s"], []}
396+
397+
part, {acc, params} ->
398+
{[acc, part], params}
399+
end)
400+
401+
IO.iodata_to_binary(iodata)
402+
end
403+
364404
# If we have a message with a stacktrace, but no exceptions, for now we store the stacktrace in
365405
# the "threads" interface and we don't fill in the "exception" interface altogether. This might
366406
# be eventually fixed in Sentry itself: https://github.com/getsentry/sentry/issues/61239

lib/sentry/interfaces.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,26 @@ defmodule Sentry.Interfaces do
9393
defstruct [:name, :version]
9494
end
9595

96+
defmodule Message do
97+
@moduledoc """
98+
The struct for the **message** interface.
99+
100+
See <https://develop.sentry.dev/sdk/event-payloads/message>.
101+
"""
102+
103+
@moduledoc since: "10.1.0"
104+
105+
@typedoc since: "10.1.0"
106+
@type t() :: %__MODULE__{
107+
message: String.t(),
108+
formatted: String.t(),
109+
params: [term()]
110+
}
111+
112+
@enforce_keys [:formatted]
113+
defstruct [:message, :params, :formatted]
114+
end
115+
96116
defmodule Exception do
97117
@moduledoc """
98118
The struct for the **exception** interface.

test/event_test.exs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ defmodule Sentry.EventTest do
191191
end
192192
end
193193

194-
test "fills in the message interface when passing the :message option" do
194+
test "fills in the message interface when passing the :message option without formatting params" do
195195
put_test_config(environment_name: "my_env")
196196

197197
assert %Event{
@@ -200,14 +200,45 @@ defmodule Sentry.EventTest do
200200
exception: [],
201201
extra: %{},
202202
level: :error,
203-
message: "Test message",
203+
message: %Interfaces.Message{} = message,
204204
platform: :elixir,
205205
release: nil,
206206
request: %{},
207207
tags: %{},
208208
user: %{},
209209
contexts: %{os: %{name: _, version: _}, runtime: %{name: _, version: _}}
210210
} = Event.create_event(message: "Test message")
211+
212+
assert message == %Interfaces.Message{formatted: "Test message"}
213+
end
214+
215+
test "fills in the message interface when passing the :message option with formatting params" do
216+
put_test_config(environment_name: "my_env")
217+
218+
assert %Event{
219+
breadcrumbs: [],
220+
environment: "my_env",
221+
exception: [],
222+
extra: %{},
223+
level: :error,
224+
message: %Interfaces.Message{} = message,
225+
platform: :elixir,
226+
release: nil,
227+
request: %{},
228+
tags: %{},
229+
user: %{},
230+
contexts: %{os: %{name: _, version: _}, runtime: %{name: _, version: _}}
231+
} =
232+
Event.create_event(
233+
message: "Interpolated string like %s",
234+
interpolation_parameters: ["this"]
235+
)
236+
237+
assert message == %Interfaces.Message{
238+
formatted: "Interpolated string like this",
239+
params: ["this"],
240+
message: "Interpolated string like %s"
241+
}
211242
end
212243

213244
test "fills in the message and threads interfaces when passing the :message option with :stacktrace" do
@@ -220,7 +251,7 @@ defmodule Sentry.EventTest do
220251
exception: [],
221252
extra: %{},
222253
level: :error,
223-
message: "Test message",
254+
message: message,
224255
platform: :elixir,
225256
release: nil,
226257
request: %{},
@@ -230,6 +261,12 @@ defmodule Sentry.EventTest do
230261
threads: [%Interfaces.Thread{id: thread_id, stacktrace: thread_stacktrace}]
231262
} = Event.create_event(message: "Test message", stacktrace: stacktrace)
232263

264+
assert message == %Sentry.Interfaces.Message{
265+
message: nil,
266+
params: nil,
267+
formatted: "Test message"
268+
}
269+
233270
assert is_binary(thread_id) and byte_size(thread_id) > 0
234271

235272
assert [
@@ -368,4 +405,23 @@ defmodule Sentry.EventTest do
368405
|> Map.keys()
369406
|> Enum.sort()
370407
end
408+
409+
describe "interpolate/2" do
410+
test "works with simple strings" do
411+
assert Event.interpolate("Hello %s!", ["world"]) == "Hello world!"
412+
end
413+
414+
test "ignores extra bindings" do
415+
assert Event.interpolate("Hello %s", ["world", "extra"]) ==
416+
"Hello world"
417+
end
418+
419+
test "works with multiple bindings" do
420+
assert Event.interpolate("Hello %s, %s!", ["world", "sup"]) == "Hello world, sup!"
421+
end
422+
423+
test "ignores unknown bindings" do
424+
assert Event.interpolate("Hello %s, %s", ["world"]) == "Hello world, %s"
425+
end
426+
end
371427
end

test/logger_backend_test.exs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ defmodule Sentry.LoggerBackendTest do
3939
assert_receive {^ref, event}
4040
assert [] = event.exception
4141
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: []>
42+
assert event.message.formatted =~ ~s<GenServer #{inspect(pid)} terminating\n>
43+
assert event.message.formatted =~ ~s<** (stop) bad return value: "I am throwing"\n>
44+
assert event.message.formatted =~ ~s<Last message: {:"$gen_cast",>
45+
assert event.message.formatted =~ ~s<State: []>
4646
assert thread.stacktrace.frames == []
4747
end
4848

@@ -54,10 +54,10 @@ defmodule Sentry.LoggerBackendTest do
5454
assert_receive {^ref, event}
5555
assert [] = event.exception
5656
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: []>
57+
assert event.message.formatted =~ ~s<GenServer #{inspect(pid)} terminating\n>
58+
assert event.message.formatted =~ ~s<** (stop) :bad_exit\n>
59+
assert event.message.formatted =~ ~s<Last message: {:"$gen_cast",>
60+
assert event.message.formatted =~ ~s<State: []>
6161
end
6262

6363
test "bad function call causing GenServer crash is reported" do
@@ -100,11 +100,11 @@ defmodule Sentry.LoggerBackendTest do
100100
assert [] = event.exception
101101
assert [thread] = event.threads
102102

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

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

@@ -147,7 +147,7 @@ defmodule Sentry.LoggerBackendTest do
147147
Logger.error("test_domain", domain: [:test_domain])
148148

149149
assert_receive {^ref, event}
150-
assert event.message == "no domain"
150+
assert event.message.formatted == "no domain"
151151
end
152152

153153
test "includes Logger metadata for keys configured to be included" do
@@ -233,7 +233,7 @@ defmodule Sentry.LoggerBackendTest do
233233
Logger.error("Testing")
234234

235235
assert_receive {^ref, event}
236-
assert event.message == "Testing"
236+
assert event.message.formatted == "Testing"
237237
after
238238
Logger.configure_backend(Sentry.LoggerBackend, capture_log_messages: false)
239239
end
@@ -251,7 +251,7 @@ defmodule Sentry.LoggerBackendTest do
251251

252252
assert_receive {^ref, event}
253253

254-
assert event.message == "Testing"
254+
assert event.message.formatted == "Testing"
255255
assert event.user.user_id == 3
256256
after
257257
Logger.configure_backend(Sentry.LoggerBackend, level: :error, capture_log_messages: false)
@@ -269,7 +269,7 @@ defmodule Sentry.LoggerBackendTest do
269269

270270
assert_receive {^ref, event}
271271

272-
assert event.message == "Error"
272+
assert event.message.formatted == "Error"
273273
after
274274
Logger.configure_backend(Sentry.LoggerBackend, level: :error, capture_log_messages: false)
275275
end
@@ -303,7 +303,7 @@ defmodule Sentry.LoggerBackendTest do
303303
Logger.error("Error", callers: [dead_pid, nil])
304304

305305
assert_receive {^ref, event}
306-
assert event.message == "Error"
306+
assert event.message.formatted == "Error"
307307
end
308308

309309
test "doesn't log events with :sentry as a domain" do

test/plug_capture_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ defmodule Sentry.PlugCaptureTest do
150150
event = decode_event_from_envelope!(body)
151151

152152
assert event["culprit"] == "Sentry.PlugCaptureTest.PhoenixController.exit/2"
153-
assert event["message"] == "Uncaught exit - :test"
153+
assert event["message"]["formatted"] == "Uncaught exit - :test"
154154
Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>)
155155
end)
156156

@@ -164,7 +164,7 @@ defmodule Sentry.PlugCaptureTest do
164164
event = decode_event_from_envelope!(body)
165165

166166
assert event["culprit"] == "Sentry.PlugCaptureTest.PhoenixController.throw/2"
167-
assert event["message"] == "Uncaught throw - :test"
167+
assert event["message"]["formatted"] == "Uncaught throw - :test"
168168
Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>)
169169
end)
170170

test/sentry/client_test.exs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ defmodule Sentry.ClientTest do
2020
test "truncates the message to a max length" do
2121
max_length = 8_192
2222
event = Event.create_event(message: String.duplicate("a", max_length + 1))
23-
assert Client.render_event(event).message == String.duplicate("a", max_length)
23+
assert Client.render_event(event).message.formatted == String.duplicate("a", max_length)
2424
end
2525

2626
test "safely inspects terms that cannot be converted to JSON" do
@@ -232,7 +232,11 @@ defmodule Sentry.ClientTest do
232232
|> Stream.take(10)
233233
|> Enum.at(0)
234234

235-
assert event["message"] == "Something went wrong"
235+
assert event["message"] == %{
236+
"formatted" => "Something went wrong",
237+
"message" => nil,
238+
"params" => nil
239+
}
236240
end
237241

238242
test "dedupes events", %{bypass: bypass} do

0 commit comments

Comments
 (0)