Skip to content

Commit 4e3e362

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

20 files changed

+1599
-26
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: 44 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,49 @@ 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+
# Sync mode (used in tests): send directly via transport
155+
case Sentry.Test.maybe_collect_metrics([metric]) do
156+
:collected ->
157+
:ok
158+
159+
:not_collecting ->
160+
if Config.dsn() do
161+
client = Config.client()
162+
163+
request_retries =
164+
Application.get_env(:sentry, :request_retries, Transport.default_retries())
165+
166+
metric
167+
|> List.wrap()
168+
|> Envelope.from_metric_events()
169+
|> Transport.encode_and_post_envelope(client, request_retries)
170+
end
171+
172+
:ok
173+
end
174+
175+
:none ->
176+
# Buffered mode (production): add to TelemetryProcessor
177+
if Config.telemetry_processor_category?(:metric) do
178+
case TelemetryProcessor.add(metric) do
179+
{:ok, {:rate_limited, data_category}} ->
180+
ClientReport.Sender.record_discarded_events(:ratelimit_backoff, data_category)
181+
182+
:ok ->
183+
:ok
184+
end
185+
end
186+
187+
:ok
188+
end
189+
end
190+
147191
defp sample_event(sample_rate) do
148192
cond do
149193
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

lib/sentry/metric.ex

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
defmodule Sentry.Metric do
2+
@moduledoc """
3+
Represents a metric that can be sent to Sentry.
4+
5+
Metrics follow the Sentry Metrics Protocol as defined in:
6+
<https://develop.sentry.dev/sdk/telemetry/metrics/>
7+
8+
This module is used by `Sentry.Metrics` to create and send metric data to Sentry.
9+
"""
10+
@moduledoc since: "13.0.0"
11+
12+
alias Sentry.Config
13+
14+
@type metric_type :: :counter | :gauge | :distribution
15+
16+
@typedoc """
17+
A metric struct.
18+
"""
19+
@type t :: %__MODULE__{
20+
type: metric_type(),
21+
name: String.t(),
22+
value: number(),
23+
timestamp: float(),
24+
trace_id: String.t() | nil,
25+
span_id: String.t() | nil,
26+
unit: String.t() | nil,
27+
attributes: map()
28+
}
29+
30+
@enforce_keys [:type, :name, :value, :timestamp]
31+
defstruct [
32+
:type,
33+
:name,
34+
:value,
35+
:timestamp,
36+
:trace_id,
37+
:span_id,
38+
:unit,
39+
attributes: %{}
40+
]
41+
42+
@sdk_version Mix.Project.config()[:version]
43+
44+
@doc """
45+
Attaches default attributes to a metric.
46+
47+
This adds Sentry-specific attributes like environment, release, SDK info, and server name.
48+
Per the Sentry Metrics spec, default attributes should be attached before the
49+
`before_send_metric` callback is applied (step 5 before step 6).
50+
"""
51+
@spec attach_default_attributes(t()) :: t()
52+
def attach_default_attributes(%__MODULE__{} = metric) do
53+
default_attrs = %{
54+
"sentry.sdk.name" => "sentry.elixir",
55+
"sentry.sdk.version" => @sdk_version
56+
}
57+
58+
# Add optional attributes if configured
59+
default_attrs =
60+
default_attrs
61+
|> maybe_put_attr("sentry.environment", Config.environment_name())
62+
|> maybe_put_attr("sentry.release", Config.release())
63+
|> maybe_put_attr("server.address", Config.server_name())
64+
65+
# Merge with user attributes (user attributes take precedence)
66+
%{metric | attributes: Map.merge(default_attrs, metric.attributes)}
67+
end
68+
69+
defp maybe_put_attr(attrs, _key, nil), do: attrs
70+
defp maybe_put_attr(attrs, key, value), do: Map.put(attrs, key, value)
71+
72+
@doc """
73+
Converts a metric to a map suitable for JSON encoding.
74+
75+
The output matches the Sentry metrics schema with top-level fields: timestamp, type, name,
76+
value, unit, trace_id, span_id, and attributes. The attributes are formatted with type
77+
information as required by the protocol.
78+
"""
79+
@spec to_map(t()) :: %{optional(atom()) => term()}
80+
def to_map(%__MODULE__{} = metric) do
81+
%{
82+
timestamp: metric.timestamp,
83+
type: to_string(metric.type),
84+
name: metric.name,
85+
value: metric.value,
86+
attributes: format_attributes(metric.attributes)
87+
}
88+
|> maybe_put(:unit, metric.unit)
89+
|> maybe_put(:trace_id, metric.trace_id)
90+
|> maybe_put(:span_id, metric.span_id)
91+
end
92+
93+
defp maybe_put(map, _key, nil), do: map
94+
defp maybe_put(map, key, value), do: Map.put(map, key, value)
95+
96+
## Helpers
97+
98+
# Format attributes to the protocol format with type information
99+
defp format_attributes(attributes) when is_map(attributes) do
100+
Enum.into(attributes, %{}, fn {key, value} ->
101+
safe_value = sanitize_attribute_value(value)
102+
{to_string(key), %{value: safe_value, type: attribute_type(safe_value)}}
103+
end)
104+
end
105+
106+
# Converts values to JSON-safe attribute types.
107+
# Primitives (string, boolean, integer, float) pass through unchanged.
108+
# Atoms are converted to strings. All other types (structs, maps, lists,
109+
# tuples, PIDs, etc.) are converted to their inspect() representation.
110+
# Note: is_boolean must come before is_atom since true/false are atoms
111+
defp sanitize_attribute_value(value) when is_binary(value), do: value
112+
defp sanitize_attribute_value(value) when is_boolean(value), do: value
113+
defp sanitize_attribute_value(value) when is_atom(value), do: Atom.to_string(value)
114+
defp sanitize_attribute_value(value) when is_integer(value), do: value
115+
defp sanitize_attribute_value(value) when is_float(value), do: value
116+
defp sanitize_attribute_value(value), do: inspect(value)
117+
118+
defp attribute_type(value) when is_boolean(value), do: "boolean"
119+
defp attribute_type(value) when is_integer(value), do: "integer"
120+
defp attribute_type(value) when is_float(value), do: "double"
121+
defp attribute_type(_value), do: "string"
122+
end

lib/sentry/metric_batch.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule Sentry.MetricBatch do
2+
@moduledoc """
3+
A batch of metric events to be sent in a single envelope item.
4+
5+
According to the Sentry Metrics Protocol, metrics are sent in batches
6+
within a single envelope item with content type `application/vnd.sentry.items.trace-metric+json`.
7+
"""
8+
@moduledoc since: "13.0.0"
9+
10+
alias Sentry.Metric
11+
12+
@type t() :: %__MODULE__{
13+
metrics: [Metric.t()]
14+
}
15+
16+
@enforce_keys [:metrics]
17+
defstruct [:metrics]
18+
end

0 commit comments

Comments
 (0)