Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for attachments #673

Merged
merged 3 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions lib/sentry/attachment.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Sentry.Attachment do
@moduledoc """
A struct to represent an **attachment**.

You can send attachments over to Sentry alongside an event. See:
<https://develop.sentry.dev/sdk/envelopes/#attachment>.

To add attachments, use `Sentry.Context.add_attachment/1`.

*Available since v10.1.0*.
"""

@moduledoc since: "10.1.0"

@typedoc """
The type for the attachment struct.
"""
@typedoc since: "10.1.0"
@type t() :: %__MODULE__{
filename: String.t(),
data: binary(),
attachment_type: String.t() | nil,
content_type: String.t() | nil
}

@enforce_keys [:filename, :data]
defstruct [:filename, :attachment_type, :content_type, :data]
end
5 changes: 4 additions & 1 deletion lib/sentry/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ defmodule Sentry.Client do
end

defp encode_and_send(%Event{} = event, _result_type = :sync, client, request_retries) do
send_result = [event] |> Envelope.new() |> Transport.post_envelope(client, request_retries)
send_result =
event
|> Envelope.from_event()
|> Transport.post_envelope(client, request_retries)

_ = maybe_log_send_result(send_result, event)
send_result
Expand Down
89 changes: 80 additions & 9 deletions lib/sentry/context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ defmodule Sentry.Context do

"""

alias Sentry.Interfaces
alias Sentry.{Attachment, Interfaces}

@typedoc """
User context.
Expand Down Expand Up @@ -149,6 +149,7 @@ defmodule Sentry.Context do
@extra_key :extra
@request_key :request
@breadcrumbs_key :breadcrumbs
@attachments_key :attachments

@doc """
Retrieves all currently-set context on the current process.
Expand All @@ -163,7 +164,8 @@ defmodule Sentry.Context do
tags: %{message_id: 456},
extra: %{},
request: %{},
breadcrumbs: []
breadcrumbs: [],
attachments: []
}

"""
Expand All @@ -172,7 +174,8 @@ defmodule Sentry.Context do
request: request_context(),
tags: tags(),
extra: extra(),
breadcrumbs: list()
breadcrumbs: list(),
attachments: list(Attachment.t())
}
def get_all do
context = get_sentry_context()
Expand All @@ -182,7 +185,8 @@ defmodule Sentry.Context do
tags: Map.get(context, @tags_key, %{}),
extra: Map.get(context, @extra_key, %{}),
request: Map.get(context, @request_key, %{}),
breadcrumbs: Map.get(context, @breadcrumbs_key, []) |> Enum.reverse() |> Enum.to_list()
breadcrumbs: Map.get(context, @breadcrumbs_key, []) |> Enum.reverse() |> Enum.to_list(),
attachments: Map.get(context, @attachments_key, []) |> Enum.reverse() |> Enum.to_list()
}
end

Expand All @@ -206,7 +210,8 @@ defmodule Sentry.Context do
tags: %{},
extra: %{detail: "bad_error", id: 123, message: "Oh no"},
request: %{},
breadcrumbs: []
breadcrumbs: [],
attachments: []
}

"""
Expand Down Expand Up @@ -241,7 +246,8 @@ defmodule Sentry.Context do
tags: %{},
extra: %{},
request: %{},
breadcrumbs: []
breadcrumbs: [],
attachments: []
}

"""
Expand All @@ -264,6 +270,7 @@ defmodule Sentry.Context do
:ok
iex> Sentry.Context.get_all()
%{
attachments: [],
breadcrumbs: [],
extra: %{},
request: %{},
Expand Down Expand Up @@ -303,6 +310,7 @@ defmodule Sentry.Context do
:ok
iex> Sentry.Context.get_all()
%{
attachments: [],
breadcrumbs: [],
extra: %{},
request: %{method: "GET", headers: %{"accept" => "application/json"}, url: "example.com"},
Expand All @@ -326,7 +334,7 @@ defmodule Sentry.Context do
iex> Sentry.Context.clear_all()
:ok
iex> Sentry.Context.get_all()
%{breadcrumbs: [], extra: %{}, request: %{}, tags: %{}, user: %{}}
%{breadcrumbs: [], extra: %{}, request: %{}, tags: %{}, user: %{}, attachments: []}

"""
@spec clear_all() :: :ok
Expand Down Expand Up @@ -374,6 +382,7 @@ defmodule Sentry.Context do
}
iex> Sentry.Context.get_all()
%{
attachments: [],
breadcrumbs: [
%{:message => "first_event", "timestamp" => 1562007480},
%{:message => "second_event", :type => "auth", "timestamp" => 1562007505},
Expand Down Expand Up @@ -425,17 +434,79 @@ defmodule Sentry.Context do
:logger.update_process_metadata(%{@logger_metadata_key => sentry_metadata})
end

@doc """
Adds an **attachment** to the current context.

Attachments stored in the context will be sent alongside each event that is
reported *within that context* (that is, within the process that the context
was set in).

Currently, there is no limit to how many attachments you can add to the context
through this function, even though there might be limits on the Sentry server side.
To clear attachments, use `clear_attachments/0`.

## Examples

iex> Sentry.Context.add_attachment(%Sentry.Attachment{filename: "foo.txt", data: "foo"})
:ok
iex> Sentry.Context.add_attachment(%Sentry.Attachment{filename: "bar.txt", data: "bar"})
:ok
iex> Sentry.Context.get_all()
%{
attachments: [
%Sentry.Attachment{filename: "bar.txt", data: "bar"},
%Sentry.Attachment{filename: "foo.txt", data: "foo"}
],
breadcrumbs: [],
extra: %{},
request: %{},
tags: %{},
user: %{}
}

"""
@doc since: "10.1.0"
@spec add_attachment(Attachment.t()) :: :ok
def add_attachment(%Attachment{} = attachment) do
new_context =
Map.update(get_sentry_context(), @attachments_key, [attachment], &(&1 ++ [attachment]))

:logger.update_process_metadata(%{@logger_metadata_key => new_context})
end

@doc """
Clears all attachments from the current context.

See `add_attachment/1`.

## Examples

iex> Sentry.Context.add_attachment(%Sentry.Attachment{filename: "foo.txt", data: "foo"})
:ok
iex> Sentry.Context.clear_attachments()
:ok
iex> Sentry.Context.get_all().attachments
[]

"""
@doc since: "10.1.0"
@spec clear_attachments() :: :ok
def clear_attachments do
new_context = Map.delete(get_sentry_context(), @attachments_key)
:logger.update_process_metadata(%{@logger_metadata_key => new_context})
end

@doc """
Returns the keys used to store context in the current process' logger metadata.

## Example

iex> Sentry.Context.context_keys()
[:breadcrumbs, :tags, :user, :extra, :request]
[:breadcrumbs, :tags, :user, :extra, :request, :attachments]

"""
@spec context_keys() :: [atom(), ...]
def context_keys do
[@breadcrumbs_key, @tags_key, @user_key, @extra_key, @request_key]
[@breadcrumbs_key, @tags_key, @user_key, @extra_key, @request_key, @attachments_key]
end
end
54 changes: 34 additions & 20 deletions lib/sentry/envelope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,35 @@ defmodule Sentry.Envelope do
@moduledoc false
# https://develop.sentry.dev/sdk/envelopes/

alias Sentry.{Config, Event, UUID}
alias Sentry.{Attachment, Config, Event, UUID}

@type t() :: %__MODULE__{
event_id: UUID.t(),
items: [Event.t(), ...]
items: [Event.t() | Attachment.t(), ...]
}

@enforce_keys [:event_id, :items]
defstruct [:event_id, :items]

@doc """
Creates a new envelope containing the given event.

Envelopes can only have a single element of type "event", so that's why we
restrict on a single-element list.
Creates a new envelope containing the given event and all of its attachments.
"""
@spec new([Event.t(), ...]) :: t()
def new([%Event{event_id: event_id}] = events) do
@spec from_event(Event.t()) :: t()
def from_event(%Event{event_id: event_id} = event) do
%__MODULE__{
event_id: event_id,
items: events
items: [event] ++ event.attachments
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe later let's add an Envelope.add_item method too when we need other types so that envelope is general enough

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep we'll probably need something like that down the line!

}
end

@doc """
Encodes the envelope into its binary representation.

For now, we support only envelopes with a single event in them.
For now, we support only envelopes with a single event and any number of attachments
in them.
"""
@spec to_binary(t()) :: {:ok, binary()} | {:error, any()}
def to_binary(%__MODULE__{items: [%Event{} = event]} = envelope) do
def to_binary(%__MODULE__{} = envelope) do
json_library = Config.json_library()

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

items_iodata = Enum.map(envelope.items, &item_to_binary(json_library, &1))

{:ok, IO.iodata_to_binary([headers_iodata, items_iodata])}
catch
{:error, _reason} = error -> error
end

defp item_to_binary(json_library, %Event{} = event) do
case event |> Sentry.Client.render_event() |> json_library.encode() do
{:ok, encoded_event} ->
body = [
headers_iodata,
~s({"type": "event", "length": #{byte_size(encoded_event)}}\n),
encoded_event,
?\n
]

{:ok, IO.iodata_to_binary(body)}
header = ~s({"type": "event", "length": #{byte_size(encoded_event)}})
[header, ?\n, encoded_event, ?\n]

{:error, _reason} = error ->
error
throw(error)
end
end

defp item_to_binary(json_library, %Attachment{} = attachment) do
header = %{"type" => "attachment", "length" => byte_size(attachment.data)}

header =
for {key, value} <- Map.take(attachment, [:filename, :content_type, :attachment_type]),
not is_nil(value),
into: header,
do: {Atom.to_string(key), value}

{:ok, header_iodata} = json_library.encode(header)

[header_iodata, ?\n, attachment.data, ?\n]
end
end
Loading