Skip to content

Commit 5e2fc75

Browse files
authored
Add support for attachments (#673)
Closes #479.
1 parent 086bb31 commit 5e2fc75

File tree

8 files changed

+232
-64
lines changed

8 files changed

+232
-64
lines changed

lib/sentry/attachment.ex

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule Sentry.Attachment do
2+
@moduledoc """
3+
A struct to represent an **attachment**.
4+
5+
You can send attachments over to Sentry alongside an event. See:
6+
<https://develop.sentry.dev/sdk/envelopes/#attachment>.
7+
8+
To add attachments, use `Sentry.Context.add_attachment/1`.
9+
10+
*Available since v10.1.0*.
11+
"""
12+
13+
@moduledoc since: "10.1.0"
14+
15+
@typedoc """
16+
The type for the attachment struct.
17+
"""
18+
@typedoc since: "10.1.0"
19+
@type t() :: %__MODULE__{
20+
filename: String.t(),
21+
data: binary(),
22+
attachment_type: String.t() | nil,
23+
content_type: String.t() | nil
24+
}
25+
26+
@enforce_keys [:filename, :data]
27+
defstruct [:filename, :attachment_type, :content_type, :data]
28+
end

lib/sentry/client.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ defmodule Sentry.Client do
197197
end
198198

199199
defp encode_and_send(%Event{} = event, _result_type = :sync, client, request_retries) do
200-
send_result = [event] |> Envelope.new() |> Transport.post_envelope(client, request_retries)
200+
send_result =
201+
event
202+
|> Envelope.from_event()
203+
|> Transport.post_envelope(client, request_retries)
201204

202205
_ = maybe_log_send_result(send_result, event)
203206
send_result

lib/sentry/context.ex

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ defmodule Sentry.Context do
3838
3939
"""
4040

41-
alias Sentry.Interfaces
41+
alias Sentry.{Attachment, Interfaces}
4242

4343
@typedoc """
4444
User context.
@@ -149,6 +149,7 @@ defmodule Sentry.Context do
149149
@extra_key :extra
150150
@request_key :request
151151
@breadcrumbs_key :breadcrumbs
152+
@attachments_key :attachments
152153

153154
@doc """
154155
Retrieves all currently-set context on the current process.
@@ -163,7 +164,8 @@ defmodule Sentry.Context do
163164
tags: %{message_id: 456},
164165
extra: %{},
165166
request: %{},
166-
breadcrumbs: []
167+
breadcrumbs: [],
168+
attachments: []
167169
}
168170
169171
"""
@@ -172,7 +174,8 @@ defmodule Sentry.Context do
172174
request: request_context(),
173175
tags: tags(),
174176
extra: extra(),
175-
breadcrumbs: list()
177+
breadcrumbs: list(),
178+
attachments: list(Attachment.t())
176179
}
177180
def get_all do
178181
context = get_sentry_context()
@@ -182,7 +185,8 @@ defmodule Sentry.Context do
182185
tags: Map.get(context, @tags_key, %{}),
183186
extra: Map.get(context, @extra_key, %{}),
184187
request: Map.get(context, @request_key, %{}),
185-
breadcrumbs: Map.get(context, @breadcrumbs_key, []) |> Enum.reverse() |> Enum.to_list()
188+
breadcrumbs: Map.get(context, @breadcrumbs_key, []) |> Enum.reverse() |> Enum.to_list(),
189+
attachments: Map.get(context, @attachments_key, []) |> Enum.reverse() |> Enum.to_list()
186190
}
187191
end
188192

@@ -206,7 +210,8 @@ defmodule Sentry.Context do
206210
tags: %{},
207211
extra: %{detail: "bad_error", id: 123, message: "Oh no"},
208212
request: %{},
209-
breadcrumbs: []
213+
breadcrumbs: [],
214+
attachments: []
210215
}
211216
212217
"""
@@ -241,7 +246,8 @@ defmodule Sentry.Context do
241246
tags: %{},
242247
extra: %{},
243248
request: %{},
244-
breadcrumbs: []
249+
breadcrumbs: [],
250+
attachments: []
245251
}
246252
247253
"""
@@ -264,6 +270,7 @@ defmodule Sentry.Context do
264270
:ok
265271
iex> Sentry.Context.get_all()
266272
%{
273+
attachments: [],
267274
breadcrumbs: [],
268275
extra: %{},
269276
request: %{},
@@ -303,6 +310,7 @@ defmodule Sentry.Context do
303310
:ok
304311
iex> Sentry.Context.get_all()
305312
%{
313+
attachments: [],
306314
breadcrumbs: [],
307315
extra: %{},
308316
request: %{method: "GET", headers: %{"accept" => "application/json"}, url: "example.com"},
@@ -326,7 +334,7 @@ defmodule Sentry.Context do
326334
iex> Sentry.Context.clear_all()
327335
:ok
328336
iex> Sentry.Context.get_all()
329-
%{breadcrumbs: [], extra: %{}, request: %{}, tags: %{}, user: %{}}
337+
%{breadcrumbs: [], extra: %{}, request: %{}, tags: %{}, user: %{}, attachments: []}
330338
331339
"""
332340
@spec clear_all() :: :ok
@@ -374,6 +382,7 @@ defmodule Sentry.Context do
374382
}
375383
iex> Sentry.Context.get_all()
376384
%{
385+
attachments: [],
377386
breadcrumbs: [
378387
%{:message => "first_event", "timestamp" => 1562007480},
379388
%{:message => "second_event", :type => "auth", "timestamp" => 1562007505},
@@ -425,17 +434,79 @@ defmodule Sentry.Context do
425434
:logger.update_process_metadata(%{@logger_metadata_key => sentry_metadata})
426435
end
427436

437+
@doc """
438+
Adds an **attachment** to the current context.
439+
440+
Attachments stored in the context will be sent alongside each event that is
441+
reported *within that context* (that is, within the process that the context
442+
was set in).
443+
444+
Currently, there is no limit to how many attachments you can add to the context
445+
through this function, even though there might be limits on the Sentry server side.
446+
To clear attachments, use `clear_attachments/0`.
447+
448+
## Examples
449+
450+
iex> Sentry.Context.add_attachment(%Sentry.Attachment{filename: "foo.txt", data: "foo"})
451+
:ok
452+
iex> Sentry.Context.add_attachment(%Sentry.Attachment{filename: "bar.txt", data: "bar"})
453+
:ok
454+
iex> Sentry.Context.get_all()
455+
%{
456+
attachments: [
457+
%Sentry.Attachment{filename: "bar.txt", data: "bar"},
458+
%Sentry.Attachment{filename: "foo.txt", data: "foo"}
459+
],
460+
breadcrumbs: [],
461+
extra: %{},
462+
request: %{},
463+
tags: %{},
464+
user: %{}
465+
}
466+
467+
"""
468+
@doc since: "10.1.0"
469+
@spec add_attachment(Attachment.t()) :: :ok
470+
def add_attachment(%Attachment{} = attachment) do
471+
new_context =
472+
Map.update(get_sentry_context(), @attachments_key, [attachment], &(&1 ++ [attachment]))
473+
474+
:logger.update_process_metadata(%{@logger_metadata_key => new_context})
475+
end
476+
477+
@doc """
478+
Clears all attachments from the current context.
479+
480+
See `add_attachment/1`.
481+
482+
## Examples
483+
484+
iex> Sentry.Context.add_attachment(%Sentry.Attachment{filename: "foo.txt", data: "foo"})
485+
:ok
486+
iex> Sentry.Context.clear_attachments()
487+
:ok
488+
iex> Sentry.Context.get_all().attachments
489+
[]
490+
491+
"""
492+
@doc since: "10.1.0"
493+
@spec clear_attachments() :: :ok
494+
def clear_attachments do
495+
new_context = Map.delete(get_sentry_context(), @attachments_key)
496+
:logger.update_process_metadata(%{@logger_metadata_key => new_context})
497+
end
498+
428499
@doc """
429500
Returns the keys used to store context in the current process' logger metadata.
430501
431502
## Example
432503
433504
iex> Sentry.Context.context_keys()
434-
[:breadcrumbs, :tags, :user, :extra, :request]
505+
[:breadcrumbs, :tags, :user, :extra, :request, :attachments]
435506
436507
"""
437508
@spec context_keys() :: [atom(), ...]
438509
def context_keys do
439-
[@breadcrumbs_key, @tags_key, @user_key, @extra_key, @request_key]
510+
[@breadcrumbs_key, @tags_key, @user_key, @extra_key, @request_key, @attachments_key]
440511
end
441512
end

lib/sentry/envelope.ex

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,35 @@ defmodule Sentry.Envelope do
22
@moduledoc false
33
# https://develop.sentry.dev/sdk/envelopes/
44

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

77
@type t() :: %__MODULE__{
88
event_id: UUID.t(),
9-
items: [Event.t(), ...]
9+
items: [Event.t() | Attachment.t(), ...]
1010
}
1111

1212
@enforce_keys [:event_id, :items]
1313
defstruct [:event_id, :items]
1414

1515
@doc """
16-
Creates a new envelope containing the given event.
17-
18-
Envelopes can only have a single element of type "event", so that's why we
19-
restrict on a single-element list.
16+
Creates a new envelope containing the given event and all of its attachments.
2017
"""
21-
@spec new([Event.t(), ...]) :: t()
22-
def new([%Event{event_id: event_id}] = events) do
18+
@spec from_event(Event.t()) :: t()
19+
def from_event(%Event{event_id: event_id} = event) do
2320
%__MODULE__{
2421
event_id: event_id,
25-
items: events
22+
items: [event] ++ event.attachments
2623
}
2724
end
2825

2926
@doc """
3027
Encodes the envelope into its binary representation.
3128
32-
For now, we support only envelopes with a single event in them.
29+
For now, we support only envelopes with a single event and any number of attachments
30+
in them.
3331
"""
3432
@spec to_binary(t()) :: {:ok, binary()} | {:error, any()}
35-
def to_binary(%__MODULE__{items: [%Event{} = event]} = envelope) do
33+
def to_binary(%__MODULE__{} = envelope) do
3634
json_library = Config.json_library()
3735

3836
headers_iodata =
@@ -41,19 +39,35 @@ defmodule Sentry.Envelope do
4139
event_id -> ~s({"event_id":"#{event_id}"}\n)
4240
end
4341

42+
items_iodata = Enum.map(envelope.items, &item_to_binary(json_library, &1))
43+
44+
{:ok, IO.iodata_to_binary([headers_iodata, items_iodata])}
45+
catch
46+
{:error, _reason} = error -> error
47+
end
48+
49+
defp item_to_binary(json_library, %Event{} = event) do
4450
case event |> Sentry.Client.render_event() |> json_library.encode() do
4551
{:ok, encoded_event} ->
46-
body = [
47-
headers_iodata,
48-
~s({"type": "event", "length": #{byte_size(encoded_event)}}\n),
49-
encoded_event,
50-
?\n
51-
]
52-
53-
{:ok, IO.iodata_to_binary(body)}
52+
header = ~s({"type": "event", "length": #{byte_size(encoded_event)}})
53+
[header, ?\n, encoded_event, ?\n]
5454

5555
{:error, _reason} = error ->
56-
error
56+
throw(error)
5757
end
5858
end
59+
60+
defp item_to_binary(json_library, %Attachment{} = attachment) do
61+
header = %{"type" => "attachment", "length" => byte_size(attachment.data)}
62+
63+
header =
64+
for {key, value} <- Map.take(attachment, [:filename, :content_type, :attachment_type]),
65+
not is_nil(value),
66+
into: header,
67+
do: {Atom.to_string(key), value}
68+
69+
{:ok, header_iodata} = json_library.encode(header)
70+
71+
[header_iodata, ?\n, attachment.data, ?\n]
72+
end
5973
end

0 commit comments

Comments
 (0)