Skip to content

Commit ecbb177

Browse files
committed
Introduce Logs handler for structured logging
1 parent 18525da commit ecbb177

21 files changed

Lines changed: 2106 additions & 9 deletions

File tree

lib/sentry/application.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ defmodule Sentry.Application do
3434
[]
3535
end
3636

37+
maybe_log_event_buffer =
38+
if Config.enabled_logs?() do
39+
[Sentry.LogEventBuffer]
40+
else
41+
[]
42+
end
43+
3744
children =
3845
[
3946
{Registry, keys: :unique, name: Sentry.Transport.SenderRegistry},
@@ -48,6 +55,7 @@ defmodule Sentry.Application do
4855
] ++
4956
maybe_http_client_spec ++
5057
maybe_span_storage ++
58+
maybe_log_event_buffer ++
5159
maybe_rate_limiter() ++
5260
[Sentry.Transport.SenderPool]
5361

lib/sentry/client.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ defmodule Sentry.Client do
1414
Envelope,
1515
Event,
1616
Interfaces,
17+
LogEvent,
1718
LoggerUtils,
1819
Options,
1920
Transaction,
@@ -134,6 +135,36 @@ defmodule Sentry.Client do
134135
end
135136
end
136137

138+
@doc """
139+
Sends a batch of log events to Sentry.
140+
141+
Log events are sent asynchronously and do not support callbacks or sampling.
142+
They are buffered and sent in batches according to the Sentry Logs Protocol.
143+
144+
Returns `{:ok, envelope_id}` on success or `{:error, reason}` on failure.
145+
"""
146+
@doc since: "12.0.0"
147+
@spec send_log_events([LogEvent.t()]) ::
148+
{:ok, envelope_id :: String.t()} | {:error, ClientError.t()}
149+
def send_log_events([]), do: {:ok, ""}
150+
151+
def send_log_events(log_events) when is_list(log_events) do
152+
case Sentry.Test.maybe_collect_logs(log_events) do
153+
:collected ->
154+
{:ok, ""}
155+
156+
:not_collecting ->
157+
client = Config.client()
158+
159+
request_retries =
160+
Application.get_env(:sentry, :request_retries, Transport.default_retries())
161+
162+
log_events
163+
|> Envelope.from_log_events()
164+
|> Transport.encode_and_post_envelope(client, request_retries)
165+
end
166+
end
167+
137168
defp sample_event(sample_rate) do
138169
cond do
139170
sample_rate == 1 -> :ok

lib/sentry/config.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,27 @@ defmodule Sentry.Config do
362362
""",
363363
default: [],
364364
keys: integrations_schema
365+
],
366+
enabled_logs: [
367+
type: :boolean,
368+
default: false,
369+
doc: """
370+
Whether to enable sending log events to Sentry. When enabled, the SDK will
371+
capture and send structured log events according to the
372+
[Sentry Logs Protocol](https://develop.sentry.dev/sdk/telemetry/logs/).
373+
Use `Sentry.LogsHandler` to capture log events from Erlang's `:logger`.
374+
"""
375+
],
376+
max_log_events: [
377+
type: :non_neg_integer,
378+
default: 100,
379+
doc: """
380+
The maximum number of log events to buffer before flushing to Sentry.
381+
Log events are buffered and sent in batches to reduce network overhead.
382+
When the buffer reaches this size, it will be flushed immediately.
383+
Otherwise, logs are flushed every 5 seconds. Only applies when `:enabled_logs`
384+
is `true`.
385+
"""
365386
]
366387
]
367388

@@ -778,6 +799,12 @@ defmodule Sentry.Config do
778799
not is_nil(fetch!(:traces_sample_rate))) or not is_nil(get(:traces_sampler))
779800
end
780801

802+
@spec enabled_logs?() :: boolean()
803+
def enabled_logs?, do: fetch!(:enabled_logs)
804+
805+
@spec max_log_events() :: non_neg_integer()
806+
def max_log_events, do: fetch!(:max_log_events)
807+
781808
@spec put_config(atom(), term()) :: :ok
782809
def put_config(key, value) when is_atom(key) do
783810
unless key in @valid_keys do

lib/sentry/envelope.ex

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,26 @@ defmodule Sentry.Envelope do
88
ClientReport,
99
Config,
1010
Event,
11+
LogEvent,
1112
Transaction,
1213
UUID
1314
}
1415

16+
@typep log_batch() :: %{
17+
__struct__: :log_batch,
18+
log_events: [LogEvent.t()]
19+
}
20+
1521
@type t() :: %__MODULE__{
1622
event_id: UUID.t(),
1723
items: [
18-
Attachment.t() | CheckIn.t() | ClientReport.t() | Event.t() | Transaction.t(),
24+
Attachment.t()
25+
| CheckIn.t()
26+
| ClientReport.t()
27+
| Event.t()
28+
| log_batch()
29+
| LogEvent.t()
30+
| Transaction.t(),
1931
...
2032
]
2133
}
@@ -68,6 +80,28 @@ defmodule Sentry.Envelope do
6880
}
6981
end
7082

83+
@doc """
84+
Creates a new envelope containing log events.
85+
86+
According to the Sentry Logs Protocol, log events are sent in batches
87+
within a single envelope item with content_type "application/vnd.sentry.items.log+json".
88+
All log events are wrapped in a single item with { items: [...] }.
89+
"""
90+
@doc since: "11.0.0"
91+
@spec from_log_events([LogEvent.t()]) :: t()
92+
def from_log_events(log_events) when is_list(log_events) do
93+
# Create a single log batch item that wraps all log events
94+
log_batch = %{
95+
__struct__: :log_batch,
96+
log_events: log_events
97+
}
98+
99+
%__MODULE__{
100+
event_id: UUID.uuid4_hex(),
101+
items: [log_batch]
102+
}
103+
end
104+
71105
@doc """
72106
Returns the "data category" of the envelope's contents (to be used in client reports and more).
73107
"""
@@ -77,6 +111,8 @@ defmodule Sentry.Envelope do
77111
| CheckIn.t()
78112
| ClientReport.t()
79113
| Event.t()
114+
| log_batch()
115+
| LogEvent.t()
80116
| Transaction.t()
81117
) ::
82118
String.t()
@@ -85,6 +121,7 @@ defmodule Sentry.Envelope do
85121
def get_data_category(%CheckIn{}), do: "monitor"
86122
def get_data_category(%ClientReport{}), do: "internal"
87123
def get_data_category(%Event{}), do: "error"
124+
def get_data_category(%{__struct__: :log_batch}), do: "log_item"
88125

89126
@doc """
90127
Encodes the envelope into its binary representation.
@@ -166,4 +203,24 @@ defmodule Sentry.Envelope do
166203
throw(error)
167204
end
168205
end
206+
207+
defp item_to_binary(json_library, %{__struct__: :log_batch, log_events: log_events}) do
208+
items = Enum.map(log_events, &LogEvent.to_map/1)
209+
payload = %{items: items}
210+
211+
case Sentry.JSON.encode(payload, json_library) do
212+
{:ok, encoded_payload} ->
213+
header = %{
214+
"type" => "log",
215+
"item_count" => length(items),
216+
"content_type" => "application/vnd.sentry.items.log+json"
217+
}
218+
219+
{:ok, encoded_header} = Sentry.JSON.encode(header, json_library)
220+
[encoded_header, ?\n, encoded_payload, ?\n]
221+
222+
{:error, _reason} = error ->
223+
throw(error)
224+
end
225+
end
169226
end

0 commit comments

Comments
 (0)