Skip to content

Commit d63b4a3

Browse files
committed
wip(metrics): add Metrics support
1 parent dd4f4ee commit d63b4a3

20 files changed

+1625
-27
lines changed

lib/sentry.ex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,17 @@ defmodule Sentry do
234234
(Sentry.LogEvent.t() -> as_boolean(Sentry.LogEvent.t()))
235235
| {module(), function_name :: atom()}
236236

237+
@typedoc """
238+
A callback to use with the `:before_send_metric` configuration option.
239+
240+
If this is `{module, function_name}`, then `module.function_name(metric)` will
241+
be called, where `metric` is of type `t:Sentry.Metric.t/0`.
242+
"""
243+
@typedoc since: "13.0.0"
244+
@type before_send_metric_callback() ::
245+
(Sentry.Metric.t() -> as_boolean(Sentry.Metric.t()))
246+
| {module(), function_name :: atom()}
247+
237248
@typedoc """
238249
The strategy to use when sending an event to Sentry.
239250
"""

lib/sentry/client.ex

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ defmodule Sentry.Client do
1515
Event,
1616
Interfaces,
1717
LoggerUtils,
18+
Metric,
1819
Options,
1920
TelemetryProcessor,
2021
Transaction,
@@ -144,6 +145,85 @@ defmodule Sentry.Client do
144145
end
145146
end
146147

148+
@spec send_metric(Metric.t()) :: :ok
149+
def send_metric(%Metric{} = metric) do
150+
result_type = Config.send_result()
151+
152+
case result_type do
153+
:sync ->
154+
case apply_before_send_metric(metric) do
155+
[%Metric{} = filtered_metric] ->
156+
case Sentry.Test.maybe_collect_metrics([filtered_metric]) do
157+
:collected ->
158+
:ok
159+
160+
:not_collecting ->
161+
if Config.dsn() do
162+
client = Config.client()
163+
164+
request_retries =
165+
Application.get_env(:sentry, :request_retries, Transport.default_retries())
166+
167+
filtered_metric
168+
|> List.wrap()
169+
|> Envelope.from_metric_events()
170+
|> Transport.encode_and_post_envelope(client, request_retries)
171+
end
172+
173+
:ok
174+
end
175+
176+
[] ->
177+
:ok
178+
end
179+
180+
:none ->
181+
# Buffered mode (production): add to TelemetryProcessor
182+
if Config.telemetry_processor_category?(:metric) do
183+
case TelemetryProcessor.add(metric) do
184+
{:ok, {:rate_limited, data_category}} ->
185+
ClientReport.Sender.record_discarded_events(:ratelimit_backoff, data_category)
186+
187+
:ok ->
188+
:ok
189+
end
190+
end
191+
192+
:ok
193+
end
194+
end
195+
196+
defp apply_before_send_metric(metric) do
197+
callback = Config.before_send_metric()
198+
199+
if callback do
200+
case call_before_send_metric(metric, callback) do
201+
%Metric{} = modified -> [modified]
202+
_ -> []
203+
end
204+
else
205+
[metric]
206+
end
207+
end
208+
209+
defp call_before_send_metric(metric, function) when is_function(function, 1) do
210+
function.(metric)
211+
rescue
212+
error ->
213+
require Logger
214+
Logger.warning("before_send_metric callback failed: #{inspect(error)}")
215+
metric
216+
end
217+
218+
defp call_before_send_metric(metric, {mod, fun}) do
219+
apply(mod, fun, [metric])
220+
rescue
221+
error ->
222+
require Logger
223+
Logger.warning("before_send_metric callback failed: #{inspect(error)}")
224+
metric
225+
end
226+
147227
defp sample_event(sample_rate) do
148228
cond do
149229
sample_rate == 1 -> :ok

lib/sentry/config.ex

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,17 @@ defmodule Sentry.Config do
396396
*Available since 12.0.0*.
397397
"""
398398
],
399+
enable_metrics: [
400+
type: :boolean,
401+
default: true,
402+
doc: """
403+
Whether to enable sending metric events to Sentry. When enabled, the SDK will
404+
capture and send metrics (counters, gauges, distributions) according to the
405+
[Sentry Metrics Protocol](https://develop.sentry.dev/sdk/telemetry/metrics/).
406+
Use `Sentry.Metrics` functions to record metrics.
407+
*Available since 13.0.0*.
408+
"""
409+
],
399410
logs: [
400411
type: :keyword_list,
401412
default: [],
@@ -434,8 +445,8 @@ defmodule Sentry.Config do
434445
]
435446
],
436447
telemetry_processor_categories: [
437-
type: {:list, {:in, [:error, :check_in, :transaction, :log]}},
438-
default: [:log],
448+
type: {:list, {:in, [:error, :check_in, :transaction, :log, :metric]}},
449+
default: [:log, :metric],
439450
doc: """
440451
List of event categories that should be processed through the TelemetryProcessor.
441452
Categories in this list use the TelemetryProcessor's ring buffer and weighted
@@ -447,6 +458,7 @@ defmodule Sentry.Config do
447458
* `:check_in` - Cron check-ins (high priority, batch_size=1)
448459
* `:transaction` - Performance transactions (medium priority, batch_size=1)
449460
* `:log` - Log entries (low priority, batch_size=100, 5s timeout)
461+
* `:metric` - Metric events (low priority, batch_size=100, 5s timeout)
450462
451463
*Available since 12.0.0*.
452464
"""
@@ -702,6 +714,17 @@ defmodule Sentry.Config do
702714
(potentially-updated) `Sentry.LogEvent`, then the updated log event is used instead.
703715
*Available since v12.0.0*.
704716
"""
717+
],
718+
before_send_metric: [
719+
type: {:or, [nil, {:fun, 1}, {:tuple, [:atom, :atom]}]},
720+
type_doc: "`t:before_send_metric_callback/0`",
721+
doc: """
722+
Allows performing operations on a metric *before* it is sent, as
723+
well as filtering out the metric altogether.
724+
If the callback returns `nil` or `false`, the metric is not reported. If it returns a
725+
(potentially-updated) `Sentry.Metric`, then the updated metric is used instead.
726+
*Available since v13.0.0*.
727+
"""
705728
]
706729
]
707730

@@ -905,6 +928,9 @@ defmodule Sentry.Config do
905928
@spec enable_logs?() :: boolean()
906929
def enable_logs?, do: fetch!(:enable_logs)
907930

931+
@spec enable_metrics?() :: boolean()
932+
def enable_metrics?, do: fetch!(:enable_metrics)
933+
908934
@spec logs() :: keyword()
909935
def logs, do: fetch!(:logs)
910936

@@ -937,6 +963,10 @@ defmodule Sentry.Config do
937963
(Sentry.LogEvent.t() -> Sentry.LogEvent.t() | nil | false) | {module(), atom()} | nil
938964
def before_send_log, do: get(:before_send_log)
939965

966+
@spec before_send_metric() ::
967+
(Sentry.Metric.t() -> Sentry.Metric.t() | nil | false) | {module(), atom()} | nil
968+
def before_send_metric, do: get(:before_send_metric)
969+
940970
@spec put_config(atom(), term()) :: :ok
941971
def put_config(key, value) when is_atom(key) do
942972
unless key in @valid_keys do

lib/sentry/envelope.ex

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ defmodule Sentry.Envelope do
1010
Event,
1111
LogBatch,
1212
LogEvent,
13+
Metric,
14+
MetricBatch,
1315
Transaction,
1416
UUID
1517
}
@@ -22,6 +24,7 @@ defmodule Sentry.Envelope do
2224
| ClientReport.t()
2325
| Event.t()
2426
| LogBatch.t()
27+
| MetricBatch.t()
2528
| Transaction.t(),
2629
...
2730
]
@@ -94,6 +97,25 @@ defmodule Sentry.Envelope do
9497
}
9598
end
9699

100+
@doc """
101+
Creates a new envelope containing metric events.
102+
103+
According to the Sentry Metrics Protocol, metrics are sent in batches
104+
within a single envelope item with content type `application/vnd.sentry.items.trace-metric+json`.
105+
All metric events are wrapped in a single item with `{ items: [...] }`.
106+
"""
107+
@doc since: "13.0.0"
108+
@spec from_metric_events([Metric.t()]) :: t()
109+
def from_metric_events(metrics) when is_list(metrics) do
110+
# Create a single metric batch item that wraps all metrics
111+
metric_batch = %MetricBatch{metrics: metrics}
112+
113+
%__MODULE__{
114+
event_id: UUID.uuid4_hex(),
115+
items: [metric_batch]
116+
}
117+
end
118+
97119
@doc """
98120
Returns the "data category" of the envelope's contents (to be used in client reports and more).
99121
"""
@@ -104,6 +126,7 @@ defmodule Sentry.Envelope do
104126
| ClientReport.t()
105127
| Event.t()
106128
| LogBatch.t()
129+
| MetricBatch.t()
107130
| Transaction.t()
108131
) ::
109132
String.t()
@@ -113,17 +136,19 @@ defmodule Sentry.Envelope do
113136
def get_data_category(%ClientReport{}), do: "internal"
114137
def get_data_category(%Event{}), do: "error"
115138
def get_data_category(%LogBatch{}), do: "log_item"
139+
def get_data_category(%MetricBatch{}), do: "trace_metric"
116140

117141
@doc """
118142
Returns the total number of payload items in the envelope.
119143
120-
For log envelopes, this counts individual log events within the LogBatch.
144+
For log and metric envelopes, this counts individual items within the batch.
121145
For other envelope types, each item counts as 1.
122146
"""
123147
@spec item_count(t()) :: non_neg_integer()
124148
def item_count(%__MODULE__{items: items}) do
125149
Enum.reduce(items, 0, fn
126150
%LogBatch{log_events: log_events}, acc -> acc + length(log_events)
151+
%MetricBatch{metrics: metrics}, acc -> acc + length(metrics)
127152
_other, acc -> acc + 1
128153
end)
129154
end
@@ -228,4 +253,24 @@ defmodule Sentry.Envelope do
228253
throw(error)
229254
end
230255
end
256+
257+
defp item_to_binary(json_library, %MetricBatch{metrics: metrics}) do
258+
items = Enum.map(metrics, &Metric.to_map/1)
259+
payload = %{items: items}
260+
261+
case Sentry.JSON.encode(payload, json_library) do
262+
{:ok, encoded_payload} ->
263+
header = %{
264+
"type" => "trace_metric",
265+
"item_count" => length(items),
266+
"content_type" => "application/vnd.sentry.items.trace-metric+json"
267+
}
268+
269+
{:ok, encoded_header} = Sentry.JSON.encode(header, json_library)
270+
[encoded_header, ?\n, encoded_payload, ?\n]
271+
272+
{:error, _reason} = error ->
273+
throw(error)
274+
end
275+
end
231276
end

0 commit comments

Comments
 (0)