diff --git a/lib/sentry.ex b/lib/sentry.ex
index a4202000..5324f951 100644
--- a/lib/sentry.ex
+++ b/lib/sentry.ex
@@ -234,6 +234,17 @@ defmodule Sentry do
(Sentry.LogEvent.t() -> as_boolean(Sentry.LogEvent.t()))
| {module(), function_name :: atom()}
+ @typedoc """
+ A callback to use with the `:before_send_metric` configuration option.
+
+ If this is `{module, function_name}`, then `module.function_name(metric)` will
+ be called, where `metric` is of type `t:Sentry.Metric.t/0`.
+ """
+ @typedoc since: "13.0.0"
+ @type before_send_metric_callback() ::
+ (Sentry.Metric.t() -> as_boolean(Sentry.Metric.t()))
+ | {module(), function_name :: atom()}
+
@typedoc """
The strategy to use when sending an event to Sentry.
"""
diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex
index c6bcb79c..4833305a 100644
--- a/lib/sentry/client.ex
+++ b/lib/sentry/client.ex
@@ -15,6 +15,7 @@ defmodule Sentry.Client do
Event,
Interfaces,
LoggerUtils,
+ Metric,
Options,
TelemetryProcessor,
Transaction,
@@ -144,6 +145,86 @@ defmodule Sentry.Client do
end
end
+ @spec send_metric(Metric.t()) :: :ok
+ def send_metric(%Metric{} = metric) do
+ result_type = Config.send_result()
+
+ case result_type do
+ :sync ->
+ case apply_before_send_metric(metric) do
+ [%Metric{} = filtered_metric] ->
+ case Sentry.Test.maybe_collect_metrics([filtered_metric]) do
+ :collected ->
+ :ok
+
+ :not_collecting ->
+ _result =
+ if Config.dsn() do
+ client = Config.client()
+
+ request_retries =
+ Application.get_env(:sentry, :request_retries, Transport.default_retries())
+
+ filtered_metric
+ |> List.wrap()
+ |> Envelope.from_metric_events()
+ |> Transport.encode_and_post_envelope(client, request_retries)
+ end
+
+ :ok
+ end
+
+ [] ->
+ :ok
+ end
+
+ :none ->
+ # Buffered mode (production): add to TelemetryProcessor
+ if Config.telemetry_processor_category?(:metric) do
+ case TelemetryProcessor.add(metric) do
+ {:ok, {:rate_limited, data_category}} ->
+ ClientReport.Sender.record_discarded_events(:ratelimit_backoff, data_category)
+
+ :ok ->
+ :ok
+ end
+ end
+
+ :ok
+ end
+ end
+
+ defp apply_before_send_metric(metric) do
+ callback = Config.before_send_metric()
+
+ if callback do
+ case call_before_send_metric(metric, callback) do
+ %Metric{} = modified -> [modified]
+ _ -> []
+ end
+ else
+ [metric]
+ end
+ end
+
+ defp call_before_send_metric(metric, function) when is_function(function, 1) do
+ function.(metric)
+ rescue
+ error ->
+ require Logger
+ Logger.warning("before_send_metric callback failed: #{inspect(error)}")
+ metric
+ end
+
+ defp call_before_send_metric(metric, {mod, fun}) do
+ apply(mod, fun, [metric])
+ rescue
+ error ->
+ require Logger
+ Logger.warning("before_send_metric callback failed: #{inspect(error)}")
+ metric
+ end
+
defp sample_event(sample_rate) do
cond do
sample_rate == 1 -> :ok
diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex
index 85126696..67604cc1 100644
--- a/lib/sentry/config.ex
+++ b/lib/sentry/config.ex
@@ -396,6 +396,17 @@ defmodule Sentry.Config do
*Available since 12.0.0*.
"""
],
+ enable_metrics: [
+ type: :boolean,
+ default: true,
+ doc: """
+ Whether to enable sending metric events to Sentry. When enabled, the SDK will
+ capture and send metrics (counters, gauges, distributions) according to the
+ [Sentry Metrics Protocol](https://develop.sentry.dev/sdk/telemetry/metrics/).
+ Use `Sentry.Metrics` functions to record metrics.
+ *Available since 13.0.0*.
+ """
+ ],
logs: [
type: :keyword_list,
default: [],
@@ -434,8 +445,8 @@ defmodule Sentry.Config do
]
],
telemetry_processor_categories: [
- type: {:list, {:in, [:error, :check_in, :transaction, :log]}},
- default: [:log],
+ type: {:list, {:in, [:error, :check_in, :transaction, :log, :metric]}},
+ default: [:log, :metric],
doc: """
List of event categories that should be processed through the TelemetryProcessor.
Categories in this list use the TelemetryProcessor's ring buffer and weighted
@@ -447,6 +458,7 @@ defmodule Sentry.Config do
* `:check_in` - Cron check-ins (high priority, batch_size=1)
* `:transaction` - Performance transactions (medium priority, batch_size=1)
* `:log` - Log entries (low priority, batch_size=100, 5s timeout)
+ * `:metric` - Metric events (low priority, batch_size=100, 5s timeout)
*Available since 12.0.0*.
"""
@@ -702,6 +714,17 @@ defmodule Sentry.Config do
(potentially-updated) `Sentry.LogEvent`, then the updated log event is used instead.
*Available since v12.0.0*.
"""
+ ],
+ before_send_metric: [
+ type: {:or, [nil, {:fun, 1}, {:tuple, [:atom, :atom]}]},
+ type_doc: "`t:before_send_metric_callback/0`",
+ doc: """
+ Allows performing operations on a metric *before* it is sent, as
+ well as filtering out the metric altogether.
+ If the callback returns `nil` or `false`, the metric is not reported. If it returns a
+ (potentially-updated) `Sentry.Metric`, then the updated metric is used instead.
+ *Available since v13.0.0*.
+ """
]
]
@@ -905,6 +928,9 @@ defmodule Sentry.Config do
@spec enable_logs?() :: boolean()
def enable_logs?, do: fetch!(:enable_logs)
+ @spec enable_metrics?() :: boolean()
+ def enable_metrics?, do: fetch!(:enable_metrics)
+
@spec logs() :: keyword()
def logs, do: fetch!(:logs)
@@ -937,6 +963,10 @@ defmodule Sentry.Config do
(Sentry.LogEvent.t() -> Sentry.LogEvent.t() | nil | false) | {module(), atom()} | nil
def before_send_log, do: get(:before_send_log)
+ @spec before_send_metric() ::
+ (Sentry.Metric.t() -> Sentry.Metric.t() | nil | false) | {module(), atom()} | nil
+ def before_send_metric, do: get(:before_send_metric)
+
@spec put_config(atom(), term()) :: :ok
def put_config(key, value) when is_atom(key) do
unless key in @valid_keys do
diff --git a/lib/sentry/envelope.ex b/lib/sentry/envelope.ex
index 7a8519e7..0e83597c 100644
--- a/lib/sentry/envelope.ex
+++ b/lib/sentry/envelope.ex
@@ -10,6 +10,8 @@ defmodule Sentry.Envelope do
Event,
LogBatch,
LogEvent,
+ Metric,
+ MetricBatch,
Transaction,
UUID
}
@@ -22,6 +24,7 @@ defmodule Sentry.Envelope do
| ClientReport.t()
| Event.t()
| LogBatch.t()
+ | MetricBatch.t()
| Transaction.t(),
...
]
@@ -94,6 +97,25 @@ defmodule Sentry.Envelope do
}
end
+ @doc """
+ Creates a new envelope containing metric events.
+
+ According to the Sentry Metrics Protocol, metrics are sent in batches
+ within a single envelope item with content type `application/vnd.sentry.items.trace-metric+json`.
+ All metric events are wrapped in a single item with `{ items: [...] }`.
+ """
+ @doc since: "13.0.0"
+ @spec from_metric_events([Metric.t()]) :: t()
+ def from_metric_events(metrics) when is_list(metrics) do
+ # Create a single metric batch item that wraps all metrics
+ metric_batch = %MetricBatch{metrics: metrics}
+
+ %__MODULE__{
+ event_id: UUID.uuid4_hex(),
+ items: [metric_batch]
+ }
+ end
+
@doc """
Returns the "data category" of the envelope's contents (to be used in client reports and more).
"""
@@ -104,6 +126,7 @@ defmodule Sentry.Envelope do
| ClientReport.t()
| Event.t()
| LogBatch.t()
+ | MetricBatch.t()
| Transaction.t()
) ::
String.t()
@@ -113,17 +136,19 @@ defmodule Sentry.Envelope do
def get_data_category(%ClientReport{}), do: "internal"
def get_data_category(%Event{}), do: "error"
def get_data_category(%LogBatch{}), do: "log_item"
+ def get_data_category(%MetricBatch{}), do: "trace_metric"
@doc """
Returns the total number of payload items in the envelope.
- For log envelopes, this counts individual log events within the LogBatch.
+ For log and metric envelopes, this counts individual items within the batch.
For other envelope types, each item counts as 1.
"""
@spec item_count(t()) :: non_neg_integer()
def item_count(%__MODULE__{items: items}) do
Enum.reduce(items, 0, fn
%LogBatch{log_events: log_events}, acc -> acc + length(log_events)
+ %MetricBatch{metrics: metrics}, acc -> acc + length(metrics)
_other, acc -> acc + 1
end)
end
@@ -228,4 +253,24 @@ defmodule Sentry.Envelope do
throw(error)
end
end
+
+ defp item_to_binary(json_library, %MetricBatch{metrics: metrics}) do
+ items = Enum.map(metrics, &Metric.to_map/1)
+ payload = %{items: items}
+
+ case Sentry.JSON.encode(payload, json_library) do
+ {:ok, encoded_payload} ->
+ header = %{
+ "type" => "trace_metric",
+ "item_count" => length(items),
+ "content_type" => "application/vnd.sentry.items.trace-metric+json"
+ }
+
+ {:ok, encoded_header} = Sentry.JSON.encode(header, json_library)
+ [encoded_header, ?\n, encoded_payload, ?\n]
+
+ {:error, _reason} = error ->
+ throw(error)
+ end
+ end
end
diff --git a/lib/sentry/metric.ex b/lib/sentry/metric.ex
new file mode 100644
index 00000000..137c4367
--- /dev/null
+++ b/lib/sentry/metric.ex
@@ -0,0 +1,122 @@
+defmodule Sentry.Metric do
+ @moduledoc """
+ Represents a metric that can be sent to Sentry.
+
+ Metrics follow the Sentry Metrics Protocol as defined in:
+
+
+ This module is used by `Sentry.Metrics` to create and send metric data to Sentry.
+ """
+ @moduledoc since: "13.0.0"
+
+ alias Sentry.Config
+
+ @type metric_type :: :counter | :gauge | :distribution
+
+ @typedoc """
+ A metric struct.
+ """
+ @type t :: %__MODULE__{
+ type: metric_type(),
+ name: String.t(),
+ value: number(),
+ timestamp: float(),
+ trace_id: String.t() | nil,
+ span_id: String.t() | nil,
+ unit: String.t() | nil,
+ attributes: map()
+ }
+
+ @enforce_keys [:type, :name, :value, :timestamp]
+ defstruct [
+ :type,
+ :name,
+ :value,
+ :timestamp,
+ :trace_id,
+ :span_id,
+ :unit,
+ attributes: %{}
+ ]
+
+ @sdk_version Mix.Project.config()[:version]
+
+ @doc """
+ Attaches default attributes to a metric.
+
+ This adds Sentry-specific attributes like environment, release, SDK info, and server name.
+ Per the Sentry Metrics spec, default attributes should be attached before the
+ `before_send_metric` callback is applied (step 5 before step 6).
+ """
+ @spec attach_default_attributes(t()) :: t()
+ def attach_default_attributes(%__MODULE__{} = metric) do
+ default_attrs = %{
+ "sentry.sdk.name" => "sentry.elixir",
+ "sentry.sdk.version" => @sdk_version
+ }
+
+ # Add optional attributes if configured
+ default_attrs =
+ default_attrs
+ |> maybe_put_attr("sentry.environment", Config.environment_name())
+ |> maybe_put_attr("sentry.release", Config.release())
+ |> maybe_put_attr("server.address", Config.server_name())
+
+ # Merge with user attributes (user attributes take precedence)
+ %{metric | attributes: Map.merge(default_attrs, metric.attributes)}
+ end
+
+ defp maybe_put_attr(attrs, _key, nil), do: attrs
+ defp maybe_put_attr(attrs, key, value), do: Map.put(attrs, key, value)
+
+ @doc """
+ Converts a metric to a map suitable for JSON encoding.
+
+ The output matches the Sentry metrics schema with top-level fields: timestamp, type, name,
+ value, unit, trace_id, span_id, and attributes. The attributes are formatted with type
+ information as required by the protocol.
+ """
+ @spec to_map(t()) :: %{optional(atom()) => term()}
+ def to_map(%__MODULE__{} = metric) do
+ %{
+ timestamp: metric.timestamp,
+ type: to_string(metric.type),
+ name: metric.name,
+ value: metric.value,
+ attributes: format_attributes(metric.attributes)
+ }
+ |> maybe_put(:unit, metric.unit)
+ |> maybe_put(:trace_id, metric.trace_id)
+ |> maybe_put(:span_id, metric.span_id)
+ end
+
+ defp maybe_put(map, _key, nil), do: map
+ defp maybe_put(map, key, value), do: Map.put(map, key, value)
+
+ ## Helpers
+
+ # Format attributes to the protocol format with type information
+ defp format_attributes(attributes) when is_map(attributes) do
+ Enum.into(attributes, %{}, fn {key, value} ->
+ safe_value = sanitize_attribute_value(value)
+ {to_string(key), %{value: safe_value, type: attribute_type(safe_value)}}
+ end)
+ end
+
+ # Converts values to JSON-safe attribute types.
+ # Primitives (string, boolean, integer, float) pass through unchanged.
+ # Atoms are converted to strings. All other types (structs, maps, lists,
+ # tuples, PIDs, etc.) are converted to their inspect() representation.
+ # Note: is_boolean must come before is_atom since true/false are atoms
+ defp sanitize_attribute_value(value) when is_binary(value), do: value
+ defp sanitize_attribute_value(value) when is_boolean(value), do: value
+ defp sanitize_attribute_value(value) when is_atom(value), do: Atom.to_string(value)
+ defp sanitize_attribute_value(value) when is_integer(value), do: value
+ defp sanitize_attribute_value(value) when is_float(value), do: value
+ defp sanitize_attribute_value(value), do: inspect(value)
+
+ defp attribute_type(value) when is_boolean(value), do: "boolean"
+ defp attribute_type(value) when is_integer(value), do: "integer"
+ defp attribute_type(value) when is_float(value), do: "double"
+ defp attribute_type(_value), do: "string"
+end
diff --git a/lib/sentry/metric_batch.ex b/lib/sentry/metric_batch.ex
new file mode 100644
index 00000000..b8750474
--- /dev/null
+++ b/lib/sentry/metric_batch.ex
@@ -0,0 +1,18 @@
+defmodule Sentry.MetricBatch do
+ @moduledoc """
+ A batch of metric events to be sent in a single envelope item.
+
+ According to the Sentry Metrics Protocol, metrics are sent in batches
+ within a single envelope item with content type `application/vnd.sentry.items.trace-metric+json`.
+ """
+ @moduledoc since: "13.0.0"
+
+ alias Sentry.Metric
+
+ @type t() :: %__MODULE__{
+ metrics: [Metric.t()]
+ }
+
+ @enforce_keys [:metrics]
+ defstruct [:metrics]
+end
diff --git a/lib/sentry/metrics.ex b/lib/sentry/metrics.ex
new file mode 100644
index 00000000..ac3b16eb
--- /dev/null
+++ b/lib/sentry/metrics.ex
@@ -0,0 +1,187 @@
+defmodule Sentry.Metrics do
+ @moduledoc """
+ Public API for recording metrics and sending them to Sentry.
+
+ Metrics follow the Sentry Metrics Protocol as defined in:
+
+
+ ## Metric Types
+
+ The SDK supports three metric types:
+
+ * **Counter** - Tracks a value that always increments
+ * **Gauge** - Tracks a value that can go up or down
+ * **Distribution** - Tracks a distribution of values for statistical aggregation
+
+ ## Usage
+
+ # Record a counter
+ Sentry.Metrics.count("button.clicks", 1)
+ Sentry.Metrics.count("button.clicks", 5, unit: "click", attributes: %{button_id: "submit"})
+
+ # Record a gauge
+ Sentry.Metrics.gauge("memory.usage", 1024, unit: "megabyte")
+
+ # Record a distribution
+ Sentry.Metrics.distribution("response.time", 42.5, unit: "millisecond")
+
+ ## Configuration
+
+ Metrics can be disabled globally via configuration:
+
+ config :sentry, enable_metrics: false
+
+ You can also filter metrics using the `:before_send_metric` callback:
+
+ config :sentry,
+ before_send_metric: fn metric ->
+ # Drop metrics from test environments
+ if metric.attributes["sentry.environment"] == "test", do: nil, else: metric
+ end
+
+ """
+ @moduledoc since: "13.0.0"
+
+ alias Sentry.{Config, Client, Metric}
+
+ @doc """
+ Records a counter metric.
+
+ Counters track values that always increment, such as the number of requests,
+ button clicks, or error counts.
+
+ ## Options
+
+ * `:unit` - The unit of measurement (e.g., "click", "request"). Optional.
+ * `:attributes` - A map of key-value pairs to attach to the metric. Optional.
+
+ ## Examples
+
+ Sentry.Metrics.count("button.clicks", 1)
+ Sentry.Metrics.count("http.requests", 5, unit: "request", attributes: %{method: "GET"})
+
+ """
+ @spec count(String.t(), number(), keyword()) :: :ok
+ def count(name, value, opts \\ []) when is_binary(name) and is_number(value) do
+ record_metric(:counter, name, value, opts)
+ end
+
+ @doc """
+ Records a gauge metric.
+
+ Gauges track values that can go up or down, such as memory usage, active connections,
+ or queue depth.
+
+ ## Options
+
+ * `:unit` - The unit of measurement (e.g., "byte", "connection"). Optional.
+ * `:attributes` - A map of key-value pairs to attach to the metric. Optional.
+
+ ## Examples
+
+ Sentry.Metrics.gauge("memory.usage", 1024, unit: "megabyte")
+ Sentry.Metrics.gauge("active.connections", 42, attributes: %{pool: "main"})
+
+ """
+ @spec gauge(String.t(), number(), keyword()) :: :ok
+ def gauge(name, value, opts \\ []) when is_binary(name) and is_number(value) do
+ record_metric(:gauge, name, value, opts)
+ end
+
+ @doc """
+ Records a distribution metric.
+
+ Distributions track a distribution of values for statistical aggregation,
+ such as response times, payload sizes, or query durations.
+
+ ## Options
+
+ * `:unit` - The unit of measurement (e.g., "millisecond", "byte"). Optional.
+ * `:attributes` - A map of key-value pairs to attach to the metric. Optional.
+
+ ## Examples
+
+ Sentry.Metrics.distribution("response.time", 42.5, unit: "millisecond")
+ Sentry.Metrics.distribution("payload.size", 2048, unit: "byte", attributes: %{endpoint: "/api"})
+
+ """
+ @spec distribution(String.t(), number(), keyword()) :: :ok
+ def distribution(name, value, opts \\ []) when is_binary(name) and is_number(value) do
+ record_metric(:distribution, name, value, opts)
+ end
+
+ ## Private Functions
+
+ defp record_metric(type, name, value, opts) do
+ if Config.enable_metrics?() do
+ unit = Keyword.get(opts, :unit)
+ attributes = Keyword.get(opts, :attributes, %{})
+
+ {trace_id, span_id} = extract_trace_context()
+ trace_id = trace_id || generate_trace_id()
+
+ # Build metric struct
+ metric = %Metric{
+ type: type,
+ name: name,
+ value: value,
+ timestamp: System.system_time(:nanosecond) / 1_000_000_000,
+ trace_id: trace_id,
+ span_id: span_id,
+ unit: unit,
+ attributes: attributes
+ }
+
+ metric = Metric.attach_default_attributes(metric)
+ Client.send_metric(metric)
+ end
+
+ :ok
+ end
+
+ defp extract_trace_context do
+ case :otel_tracer.current_span_ctx() do
+ :undefined ->
+ {nil, nil}
+
+ span_ctx ->
+ trace_id = :otel_span.trace_id(span_ctx)
+ span_id = :otel_span.span_id(span_ctx)
+
+ if trace_id != 0 and span_id != 0 do
+ {format_trace_id(trace_id), format_span_id(span_id)}
+ else
+ {nil, nil}
+ end
+ end
+ rescue
+ e in [UndefinedFunctionError, ArgumentError] ->
+ require Logger
+ Logger.debug("Failed to extract OpenTelemetry trace context: #{inspect(e)}")
+ {nil, nil}
+ end
+
+ # Format trace_id as 32-character hex string
+ defp format_trace_id(trace_id) when is_integer(trace_id) do
+ trace_id
+ |> Integer.to_string(16)
+ |> String.pad_leading(32, "0")
+ |> String.downcase()
+ end
+
+ # Format span_id as 16-character hex string
+ defp format_span_id(span_id) when is_integer(span_id) do
+ span_id
+ |> Integer.to_string(16)
+ |> String.pad_leading(16, "0")
+ |> String.downcase()
+ end
+
+ # Generate a random trace_id as fallback when no active span exists
+ # Per spec: "The trace_id field is REQUIRED on every metric payload"
+ defp generate_trace_id do
+ # Generate 16 random bytes (128 bits) and format as 32-char hex string
+ :crypto.strong_rand_bytes(16)
+ |> Base.encode16(case: :lower)
+ end
+end
diff --git a/lib/sentry/telemetry/category.ex b/lib/sentry/telemetry/category.ex
index fa73a7ba..51acee5f 100644
--- a/lib/sentry/telemetry/category.ex
+++ b/lib/sentry/telemetry/category.ex
@@ -11,19 +11,20 @@ defmodule Sentry.Telemetry.Category do
* `:check_in` - Cron check-ins (high priority)
* `:transaction` - Performance transactions (medium priority)
* `:log` - Log entries (low priority)
+ * `:metric` - Metric events (low priority)
## Priorities and Weights
* `:critical` - weight 5 (errors)
* `:high` - weight 4 (check-ins)
* `:medium` - weight 3 (transactions)
- * `:low` - weight 2 (logs)
+ * `:low` - weight 2 (logs, metrics)
"""
@moduledoc since: "12.0.0"
@typedoc "Telemetry category types."
- @type t :: :error | :check_in | :transaction | :log
+ @type t :: :error | :check_in | :transaction | :log | :metric
@typedoc "Priority levels for categories."
@type priority :: :critical | :high | :medium | :low
@@ -36,7 +37,7 @@ defmodule Sentry.Telemetry.Category do
}
@priorities [:critical, :high, :medium, :low]
- @categories [:error, :check_in, :transaction, :log]
+ @categories [:error, :check_in, :transaction, :log, :metric]
@weights %{
critical: 5,
@@ -49,7 +50,8 @@ defmodule Sentry.Telemetry.Category do
error: %{capacity: 100, batch_size: 1, timeout: nil},
check_in: %{capacity: 100, batch_size: 1, timeout: nil},
transaction: %{capacity: 1000, batch_size: 1, timeout: nil},
- log: %{capacity: 1000, batch_size: 100, timeout: 5000}
+ log: %{capacity: 1000, batch_size: 100, timeout: 5000},
+ metric: %{capacity: 1000, batch_size: 100, timeout: 5000}
}
@doc """
@@ -69,12 +71,16 @@ defmodule Sentry.Telemetry.Category do
iex> Sentry.Telemetry.Category.priority(:log)
:low
+ iex> Sentry.Telemetry.Category.priority(:metric)
+ :low
+
"""
@spec priority(t()) :: priority()
def priority(:error), do: :critical
def priority(:check_in), do: :high
def priority(:transaction), do: :medium
def priority(:log), do: :low
+ def priority(:metric), do: :low
@doc """
Returns the weight for a given priority level.
@@ -121,6 +127,9 @@ defmodule Sentry.Telemetry.Category do
iex> Sentry.Telemetry.Category.default_config(:log)
%{capacity: 1000, batch_size: 100, timeout: 5000}
+ iex> Sentry.Telemetry.Category.default_config(:metric)
+ %{capacity: 1000, batch_size: 100, timeout: 5000}
+
"""
@spec default_config(t()) :: config()
def default_config(category) when category in @categories do
@@ -133,7 +142,7 @@ defmodule Sentry.Telemetry.Category do
## Examples
iex> Sentry.Telemetry.Category.all()
- [:error, :check_in, :transaction, :log]
+ [:error, :check_in, :transaction, :log, :metric]
"""
@spec all() :: [t()]
@@ -167,10 +176,14 @@ defmodule Sentry.Telemetry.Category do
iex> Sentry.Telemetry.Category.data_category(:log)
"log_item"
+ iex> Sentry.Telemetry.Category.data_category(:metric)
+ "trace_metric"
+
"""
@spec data_category(t()) :: String.t()
def data_category(:error), do: "error"
def data_category(:check_in), do: "monitor"
def data_category(:transaction), do: "transaction"
def data_category(:log), do: "log_item"
+ def data_category(:metric), do: "trace_metric"
end
diff --git a/lib/sentry/telemetry/scheduler.ex b/lib/sentry/telemetry/scheduler.ex
index 68495df5..51e3e944 100644
--- a/lib/sentry/telemetry/scheduler.ex
+++ b/lib/sentry/telemetry/scheduler.ex
@@ -35,7 +35,19 @@ defmodule Sentry.Telemetry.Scheduler do
alias __MODULE__
alias Sentry.Telemetry.{Buffer, Category}
- alias Sentry.{CheckIn, ClientReport, Config, Envelope, Event, LogEvent, Transaction, Transport}
+
+ alias Sentry.{
+ CheckIn,
+ ClientReport,
+ Config,
+ Envelope,
+ Event,
+ LogEvent,
+ Metric,
+ Transaction,
+ Transport
+ }
+
alias Sentry.Transport.RateLimiter
@default_capacity 1000
@@ -44,7 +56,8 @@ defmodule Sentry.Telemetry.Scheduler do
error: GenServer.server(),
check_in: GenServer.server(),
transaction: GenServer.server(),
- log: GenServer.server()
+ log: GenServer.server(),
+ metric: GenServer.server()
}
defstruct [
@@ -82,7 +95,7 @@ defmodule Sentry.Telemetry.Scheduler do
## Examples
iex> Sentry.Telemetry.Scheduler.build_priority_cycle()
- [:error, :error, :error, :error, :error, :check_in, :check_in, :check_in, :check_in, :transaction, :transaction, :transaction, :log, :log]
+ [:error, :error, :error, :error, :error, :check_in, :check_in, :check_in, :check_in, :transaction, :transaction, :transaction, :log, :log, :metric, :metric]
"""
@spec build_priority_cycle(map() | nil) :: [Category.t()]
@@ -261,6 +274,10 @@ defmodule Sentry.Telemetry.Scheduler do
process_and_send_logs(state, log_events, &send_envelope/2)
end
+ defp send_items(state, :metric, metrics) do
+ process_and_send_metrics(state, metrics, &send_envelope/2)
+ end
+
defp flush_all_buffers(%Scheduler{} = state) do
for {category, buffer} <- state.buffers do
items = Buffer.drain(buffer)
@@ -284,6 +301,9 @@ defmodule Sentry.Telemetry.Scheduler do
:log ->
process_and_send_logs(state, items, &send_envelope_direct/2)
+
+ :metric ->
+ process_and_send_metrics(state, items, &send_envelope_direct/2)
end
end
end
@@ -357,6 +377,28 @@ defmodule Sentry.Telemetry.Scheduler do
end
end
+ defp process_and_send_metrics(%{on_envelope: on_envelope} = state, metrics, send_fn) do
+ processed_metrics = apply_before_send_metric_callbacks(metrics)
+
+ if processed_metrics != [] do
+ if is_nil(on_envelope) do
+ case Sentry.Test.maybe_collect_metrics(processed_metrics) do
+ :collected ->
+ state
+
+ :not_collecting ->
+ envelope = Envelope.from_metric_events(processed_metrics)
+ send_fn.(state, envelope)
+ end
+ else
+ envelope = Envelope.from_metric_events(processed_metrics)
+ send_fn.(state, envelope)
+ end
+ else
+ state
+ end
+ end
+
defp apply_before_send_log_callbacks(log_events) do
callback = Config.before_send_log()
@@ -388,6 +430,37 @@ defmodule Sentry.Telemetry.Scheduler do
log_event
end
+ defp apply_before_send_metric_callbacks(metrics) do
+ callback = Config.before_send_metric()
+
+ if callback do
+ for metric <- metrics,
+ %Metric{} = modified_metric <- [call_before_send_metric(metric, callback)] do
+ modified_metric
+ end
+ else
+ metrics
+ end
+ end
+
+ defp call_before_send_metric(metric, function) when is_function(function, 1) do
+ function.(metric)
+ rescue
+ error ->
+ Logger.warning("before_send_metric callback failed: #{inspect(error)}")
+
+ metric
+ end
+
+ defp call_before_send_metric(metric, {mod, fun}) do
+ apply(mod, fun, [metric])
+ rescue
+ error ->
+ Logger.warning("before_send_metric callback failed: #{inspect(error)}")
+
+ metric
+ end
+
defp advance_cycle(%Scheduler{} = state) do
cycle_length = length(state.priority_cycle)
new_position = rem(state.cycle_position + 1, cycle_length)
@@ -556,7 +629,8 @@ defmodule Sentry.Telemetry.Scheduler do
{:error, :critical},
{:check_in, :high},
{:transaction, :medium},
- {:log, :low}
+ {:log, :low},
+ {:metric, :low}
]
end
end
diff --git a/lib/sentry/telemetry_processor.ex b/lib/sentry/telemetry_processor.ex
index 9d704e7b..81ddb84a 100644
--- a/lib/sentry/telemetry_processor.ex
+++ b/lib/sentry/telemetry_processor.ex
@@ -30,6 +30,9 @@ defmodule Sentry.TelemetryProcessor do
# Add log events to the buffer
TelemetryProcessor.add(processor, %Sentry.LogEvent{...})
+ # Add metrics to the buffer
+ TelemetryProcessor.add(processor, %Sentry.Metric{...})
+
# Flush all pending items
TelemetryProcessor.flush(processor)
@@ -40,7 +43,7 @@ defmodule Sentry.TelemetryProcessor do
alias Sentry.Telemetry.{Buffer, Category, Scheduler}
alias Sentry.Transport.RateLimiter
- alias Sentry.{CheckIn, Event, LogEvent, Transaction}
+ alias Sentry.{CheckIn, Event, LogEvent, Metric, Transaction}
@default_name __MODULE__
@@ -97,7 +100,7 @@ defmodule Sentry.TelemetryProcessor do
Returns `:ok` when the item was added, or `{:ok, {:rate_limited, data_category}}` when
the item was dropped due to an active rate limit.
"""
- @spec add(Event.t() | CheckIn.t() | Transaction.t() | LogEvent.t()) ::
+ @spec add(Event.t() | CheckIn.t() | Transaction.t() | LogEvent.t() | Metric.t()) ::
:ok | {:ok, {:rate_limited, String.t()}}
def add(%Event{} = item) do
add(processor_name(), item)
@@ -115,6 +118,10 @@ defmodule Sentry.TelemetryProcessor do
add(processor_name(), item)
end
+ def add(%Metric{} = item) do
+ add(processor_name(), item)
+ end
+
@doc """
Adds an event to the appropriate buffer.
@@ -123,7 +130,10 @@ defmodule Sentry.TelemetryProcessor do
Returns `:ok` when the item was added, or `{:ok, {:rate_limited, data_category}}` when
the item was dropped due to an active rate limit.
"""
- @spec add(Supervisor.supervisor(), Event.t() | CheckIn.t() | Transaction.t() | LogEvent.t()) ::
+ @spec add(
+ Supervisor.supervisor(),
+ Event.t() | CheckIn.t() | Transaction.t() | LogEvent.t() | Metric.t()
+ ) ::
:ok | {:ok, {:rate_limited, String.t()}}
def add(processor, %Event{} = item) when is_atom(processor),
do: add_to_buffer(processor, :error, item)
@@ -145,6 +155,11 @@ defmodule Sentry.TelemetryProcessor do
def add(processor, %LogEvent{} = item), do: add_to_buffer(processor, :log, item)
+ def add(processor, %Metric{} = item) when is_atom(processor),
+ do: add_to_buffer(processor, :metric, item)
+
+ def add(processor, %Metric{} = item), do: add_to_buffer(processor, :metric, item)
+
@doc """
Flushes all buffers by draining their contents and sending all items.
@@ -178,7 +193,8 @@ defmodule Sentry.TelemetryProcessor do
Returns the buffer pid for a given category.
"""
@spec get_buffer(Supervisor.supervisor(), Category.t()) :: pid()
- def get_buffer(processor, category) when category in [:error, :check_in, :transaction, :log] do
+ def get_buffer(processor, category)
+ when category in [:error, :check_in, :transaction, :log, :metric] do
children = Supervisor.which_children(processor)
buffer_id = buffer_id(category)
@@ -208,7 +224,7 @@ defmodule Sentry.TelemetryProcessor do
Returns 0 if the processor is not running.
"""
@spec buffer_size(Category.t()) :: non_neg_integer()
- def buffer_size(category) when category in [:error, :check_in, :transaction, :log] do
+ def buffer_size(category) when category in [:error, :check_in, :transaction, :log, :metric] do
buffer_size(processor_name(), category)
end
@@ -218,7 +234,8 @@ defmodule Sentry.TelemetryProcessor do
Returns 0 if the processor is not running.
"""
@spec buffer_size(Supervisor.supervisor(), Category.t()) :: non_neg_integer()
- def buffer_size(processor, category) when category in [:error, :check_in, :transaction, :log] do
+ def buffer_size(processor, category)
+ when category in [:error, :check_in, :transaction, :log, :metric] do
case safe_get_buffer(processor, category) do
{:ok, buffer} -> Buffer.size(buffer)
:error -> 0
@@ -308,7 +325,8 @@ defmodule Sentry.TelemetryProcessor do
error: Map.fetch!(buffer_names, :error),
check_in: Map.fetch!(buffer_names, :check_in),
transaction: Map.fetch!(buffer_names, :transaction),
- log: Map.fetch!(buffer_names, :log)
+ log: Map.fetch!(buffer_names, :log),
+ metric: Map.fetch!(buffer_names, :metric)
},
name: scheduler_name(processor_name),
capacity: transport_capacity
@@ -330,6 +348,7 @@ defmodule Sentry.TelemetryProcessor do
defp buffer_id(:check_in), do: :check_in_buffer
defp buffer_id(:transaction), do: :transaction_buffer
defp buffer_id(:log), do: :log_buffer
+ defp buffer_id(:metric), do: :metric_buffer
@doc false
@spec buffer_name(atom(), Category.t()) :: atom()
diff --git a/lib/sentry/test.ex b/lib/sentry/test.ex
index 514806f1..e6635459 100644
--- a/lib/sentry/test.ex
+++ b/lib/sentry/test.ex
@@ -80,6 +80,7 @@ defmodule Sentry.Test do
@events_key :events
@transactions_key :transactions
@logs_key :logs
+ @metrics_key :metrics
# Used internally when reporting an event, *before* reporting the actual event.
@doc false
@@ -137,6 +138,48 @@ defmodule Sentry.Test do
end
end
+ # Used internally when reporting metric events, *before* reporting the actual metric events.
+ @doc false
+ @spec maybe_collect_metrics([Sentry.Metric.t()]) :: :collected | :not_collecting
+ def maybe_collect_metrics(metrics) when is_list(metrics) do
+ if Sentry.Config.test_mode?() do
+ dsn_set? = not is_nil(Sentry.Config.dsn())
+ ensure_ownership_server_started()
+
+ case NimbleOwnership.fetch_owner(@server, callers(), @metrics_key) do
+ {:ok, owner_pid} ->
+ result =
+ NimbleOwnership.get_and_update(
+ @server,
+ owner_pid,
+ @metrics_key,
+ fn metrics_list ->
+ {:collected, (metrics_list || []) ++ metrics}
+ end
+ )
+
+ case result do
+ {:ok, :collected} ->
+ :collected
+
+ {:error, error} ->
+ raise ArgumentError,
+ "cannot collect Sentry metrics: #{Exception.message(error)}"
+ end
+
+ :error when dsn_set? ->
+ :not_collecting
+
+ # If the :dsn option is not set and we didn't capture the item, it's alright,
+ # we can just swallow it.
+ :error ->
+ :collected
+ end
+ else
+ :not_collecting
+ end
+ end
+
@doc false
def maybe_collect(item, collection_key) do
if Sentry.Config.test_mode?() do
@@ -200,6 +243,7 @@ defmodule Sentry.Test do
start_collecting(key: @events_key)
start_collecting(key: @transactions_key)
start_collecting(key: @logs_key)
+ start_collecting(key: @metrics_key)
# Allow the TelemetryProcessor scheduler to collect log events on behalf of this process.
# Logs flow through the scheduler (a separate process) and need explicit
@@ -217,6 +261,12 @@ defmodule Sentry.Test do
{:error, %NimbleOwnership.Error{reason: {:already_allowed, _}}} -> :ok
{:error, _} -> :ok
end
+
+ case NimbleOwnership.allow(@server, self(), scheduler_pid, @metrics_key) do
+ :ok -> :ok
+ {:error, %NimbleOwnership.Error{reason: {:already_allowed, _}}} -> :ok
+ {:error, _} -> :ok
+ end
end
catch
:exit, _ -> :ok
@@ -487,6 +537,53 @@ defmodule Sentry.Test do
end
end
+ @doc """
+ Pops all the collected metric events from the current process.
+
+ This function returns a list of all the metric events that have been collected from the current
+ process and all the processes that were allowed through it. If the current process
+ is not collecting metric events, this function raises an error.
+
+ After this function returns, the current process will still be collecting metric events, but
+ the collected metric events will be reset to `[]`.
+
+ ## Examples
+
+ iex> Sentry.Test.start_collecting_sentry_reports()
+ :ok
+ iex> Sentry.Metrics.count("button.clicks", 1)
+ :ok
+ iex> [%Sentry.Metric{} = metric] = Sentry.Test.pop_sentry_metrics()
+ iex> metric.name
+ "button.clicks"
+
+ """
+ @doc since: "13.0.0"
+ @spec pop_sentry_metrics(pid()) :: [Sentry.Metric.t()]
+ def pop_sentry_metrics(owner_pid \\ self()) when is_pid(owner_pid) do
+ result =
+ try do
+ NimbleOwnership.get_and_update(@server, owner_pid, @metrics_key, fn
+ nil -> {:not_collecting, []}
+ metrics when is_list(metrics) -> {metrics, []}
+ end)
+ catch
+ :exit, {:noproc, _} ->
+ raise ArgumentError, "not collecting reported metrics from #{inspect(owner_pid)}"
+ end
+
+ case result do
+ {:ok, :not_collecting} ->
+ raise ArgumentError, "not collecting reported metrics from #{inspect(owner_pid)}"
+
+ {:ok, metrics} ->
+ metrics
+
+ {:error, error} when is_exception(error) ->
+ raise ArgumentError, "cannot pop Sentry metrics: #{Exception.message(error)}"
+ end
+ end
+
## Helpers
defp ensure_ownership_server_started do
diff --git a/test/envelope_test.exs b/test/envelope_test.exs
index 3a9357fa..feea8995 100644
--- a/test/envelope_test.exs
+++ b/test/envelope_test.exs
@@ -3,7 +3,7 @@ defmodule Sentry.EnvelopeTest do
import Sentry.TestHelpers
- alias Sentry.{Attachment, CheckIn, ClientReport, Envelope, Event, LogEvent}
+ alias Sentry.{Attachment, CheckIn, ClientReport, Envelope, Event, LogEvent, Metric}
describe "to_binary/1" do
test "encodes an envelope" do
@@ -246,4 +246,94 @@ defmodule Sentry.EnvelopeTest do
timestamp: "2024-10-12T13:21:13"
}) == "error"
end
+
+ describe "from_metric_events/1" do
+ test "creates an envelope with metric batch" do
+ put_test_config(environment_name: "production", release: "1.0.0")
+
+ metrics = [
+ %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_588_601_261.535_386
+ },
+ %Metric{
+ type: :gauge,
+ name: "test.gauge",
+ value: 42.5,
+ timestamp: 1_588_601_261.544_196,
+ unit: "ms"
+ }
+ ]
+
+ # Attach default attributes (as done in the Metrics module)
+ metrics = Enum.map(metrics, &Metric.attach_default_attributes/1)
+
+ envelope = Envelope.from_metric_events(metrics)
+
+ assert {:ok, encoded} = Envelope.to_binary(envelope)
+
+ assert [id_line, header_line, payload_line] = String.split(encoded, "\n", trim: true)
+ assert %{"event_id" => _} = decode!(id_line)
+
+ decoded_header = decode!(header_line)
+ assert decoded_header["type"] == "trace_metric"
+ assert decoded_header["item_count"] == 2
+ assert decoded_header["content_type"] == "application/vnd.sentry.items.trace-metric+json"
+
+ decoded_payload = decode!(payload_line)
+ assert %{"items" => items} = decoded_payload
+ assert length(items) == 2
+
+ [counter, gauge] = items
+
+ # Verify counter metric
+ assert counter["type"] == "counter"
+ assert counter["name"] == "test.counter"
+ assert counter["value"] == 1
+ assert counter["timestamp"] == 1_588_601_261.535_386
+ assert counter["unit"] == nil
+ assert is_map(counter["attributes"])
+ assert counter["attributes"]["sentry.environment"]["value"] == "production"
+ assert counter["attributes"]["sentry.release"]["value"] == "1.0.0"
+
+ # Verify gauge metric
+ assert gauge["type"] == "gauge"
+ assert gauge["name"] == "test.gauge"
+ assert gauge["value"] == 42.5
+ assert gauge["timestamp"] == 1_588_601_261.544_196
+ assert gauge["unit"] == "ms"
+ assert is_map(gauge["attributes"])
+ end
+
+ test "counts metric events in a metric envelope" do
+ metrics =
+ Enum.map(1..10, fn i ->
+ %Metric{
+ type: :counter,
+ name: "test.metric.#{i}",
+ value: i,
+ timestamp: System.system_time(:nanosecond) / 1_000_000_000
+ }
+ end)
+
+ envelope = Envelope.from_metric_events(metrics)
+ assert Envelope.item_count(envelope) == 10
+ end
+
+ test "returns trace_metric data category" do
+ metrics = [
+ %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_588_601_261.535_386
+ }
+ ]
+
+ metric_batch = %Sentry.MetricBatch{metrics: metrics}
+ assert Envelope.get_data_category(metric_batch) == "trace_metric"
+ end
+ end
end
diff --git a/test/sentry/config_test.exs b/test/sentry/config_test.exs
index e5c26ac7..8eebcc5b 100644
--- a/test/sentry/config_test.exs
+++ b/test/sentry/config_test.exs
@@ -376,4 +376,34 @@ defmodule Sentry.ConfigTest do
end
end
end
+
+ describe ":enable_metrics" do
+ test "defaults to true" do
+ config = Config.validate!([])
+ assert config[:enable_metrics] == true
+ end
+
+ test "can be set to false" do
+ config = Config.validate!(enable_metrics: false)
+ assert config[:enable_metrics] == false
+ end
+ end
+
+ describe ":before_send_metric" do
+ test "accepts a function callback" do
+ callback = fn metric -> metric end
+ config = Config.validate!(before_send_metric: callback)
+ assert is_function(config[:before_send_metric], 1)
+ end
+
+ test "accepts a {module, function} tuple" do
+ config = Config.validate!(before_send_metric: {MyModule, :my_function})
+ assert config[:before_send_metric] == {MyModule, :my_function}
+ end
+
+ test "defaults to nil" do
+ config = Config.validate!([])
+ assert config[:before_send_metric] == nil
+ end
+ end
end
diff --git a/test/sentry/metric_batch_test.exs b/test/sentry/metric_batch_test.exs
new file mode 100644
index 00000000..20264662
--- /dev/null
+++ b/test/sentry/metric_batch_test.exs
@@ -0,0 +1,35 @@
+defmodule Sentry.MetricBatchTest do
+ use Sentry.Case, async: true
+
+ alias Sentry.{Metric, MetricBatch}
+
+ describe "struct" do
+ test "creates a metric batch with a list of metrics" do
+ metrics = [
+ %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_588_601_261.535_386
+ },
+ %Metric{
+ type: :gauge,
+ name: "test.gauge",
+ value: 42.5,
+ timestamp: 1_588_601_261.544_196
+ }
+ ]
+
+ metric_batch = %MetricBatch{metrics: metrics}
+
+ assert metric_batch.metrics == metrics
+ assert length(metric_batch.metrics) == 2
+ end
+
+ test "enforces required :metrics key" do
+ assert_raise ArgumentError, ~r/the following keys must also be given/, fn ->
+ struct!(MetricBatch, %{})
+ end
+ end
+ end
+end
diff --git a/test/sentry/metric_test.exs b/test/sentry/metric_test.exs
new file mode 100644
index 00000000..84f29d7b
--- /dev/null
+++ b/test/sentry/metric_test.exs
@@ -0,0 +1,178 @@
+defmodule Sentry.MetricTest do
+ use Sentry.Case, async: true
+
+ import Sentry.TestHelpers
+
+ alias Sentry.Metric
+
+ describe "attach_default_attributes/1" do
+ test "adds default sentry attributes" do
+ put_test_config(environment_name: "test", release: "1.0.0", server_name: "server1")
+
+ metric = %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_234_567_890.0,
+ attributes: %{}
+ }
+
+ result = Metric.attach_default_attributes(metric)
+
+ assert result.attributes["sentry.sdk.name"] == "sentry.elixir"
+ assert result.attributes["sentry.sdk.version"] == Mix.Project.config()[:version]
+ assert result.attributes["sentry.environment"] == "test"
+ assert result.attributes["sentry.release"] == "1.0.0"
+ assert result.attributes["server.address"] == "server1"
+ end
+
+ test "omits nil default attributes" do
+ # Don't configure environment_name, release, or server_name
+ # (or set them to valid non-nil values but check they're only included if non-nil)
+ metric = %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_234_567_890.0,
+ attributes: %{}
+ }
+
+ result = Metric.attach_default_attributes(metric)
+
+ # SDK name and version should always be present
+ assert result.attributes["sentry.sdk.name"] == "sentry.elixir"
+ assert is_binary(result.attributes["sentry.sdk.version"])
+
+ # Optional attributes behavior:
+ # If Config returns nil, they should not be added
+ # We can't test this directly without mocking Config, so we just verify
+ # that the function doesn't crash and includes at minimum the SDK attrs
+ end
+
+ test "preserves user attributes" do
+ put_test_config(environment_name: "test")
+
+ metric = %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_234_567_890.0,
+ attributes: %{
+ "user_key" => "user_value",
+ "custom.attribute" => 42
+ }
+ }
+
+ result = Metric.attach_default_attributes(metric)
+
+ # User attributes should be preserved
+ assert result.attributes["user_key"] == "user_value"
+ assert result.attributes["custom.attribute"] == 42
+
+ # Default attributes should also be present
+ assert result.attributes["sentry.sdk.name"] == "sentry.elixir"
+ assert result.attributes["sentry.environment"] == "test"
+ end
+
+ test "user attributes take precedence over defaults" do
+ put_test_config(environment_name: "production")
+
+ metric = %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_234_567_890.0,
+ attributes: %{
+ "sentry.environment" => "custom_environment"
+ }
+ }
+
+ result = Metric.attach_default_attributes(metric)
+
+ # User-provided value should take precedence
+ assert result.attributes["sentry.environment"] == "custom_environment"
+ end
+ end
+
+ describe "to_map/1" do
+ test "converts metric to map with required fields" do
+ metric = %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 5,
+ timestamp: 1_234_567_890.0
+ }
+
+ result = Metric.to_map(metric)
+
+ assert result.type == "counter"
+ assert result.name == "test.counter"
+ assert result.value == 5
+ assert result.timestamp == 1_234_567_890.0
+ assert is_map(result.attributes)
+ refute Map.has_key?(result, :unit)
+ refute Map.has_key?(result, :trace_id)
+ refute Map.has_key?(result, :span_id)
+ end
+
+ test "includes optional fields when present" do
+ metric = %Metric{
+ type: :gauge,
+ name: "test.gauge",
+ value: 42,
+ timestamp: 1_234_567_890.0,
+ unit: "byte",
+ trace_id: "abc123",
+ span_id: "def456"
+ }
+
+ result = Metric.to_map(metric)
+
+ assert result.unit == "byte"
+ assert result.trace_id == "abc123"
+ assert result.span_id == "def456"
+ end
+
+ test "formats attributes with type information" do
+ metric = %Metric{
+ type: :distribution,
+ name: "test.distribution",
+ value: 100.5,
+ timestamp: 1_234_567_890.0,
+ attributes: %{
+ "endpoint" => "/api/users",
+ "status_code" => 200,
+ "enabled" => true
+ }
+ }
+
+ result = Metric.to_map(metric)
+
+ assert result.attributes["endpoint"] == %{value: "/api/users", type: "string"}
+ assert result.attributes["status_code"] == %{value: 200, type: "integer"}
+ assert result.attributes["enabled"] == %{value: true, type: "boolean"}
+ end
+
+ test "sanitizes complex attribute values to strings" do
+ metric = %Metric{
+ type: :counter,
+ name: "test.counter",
+ value: 1,
+ timestamp: 1_234_567_890.0,
+ attributes: %{
+ pid: self(),
+ list: [1, 2, 3],
+ map: %{nested: "value"},
+ tuple: {:ok, "value"}
+ }
+ }
+
+ result = Metric.to_map(metric)
+
+ assert is_binary(result.attributes["pid"].value)
+ assert is_binary(result.attributes["list"].value)
+ assert is_binary(result.attributes["map"].value)
+ assert is_binary(result.attributes["tuple"].value)
+ end
+ end
+end
diff --git a/test/sentry/metrics_integration_test.exs b/test/sentry/metrics_integration_test.exs
new file mode 100644
index 00000000..3f2748bf
--- /dev/null
+++ b/test/sentry/metrics_integration_test.exs
@@ -0,0 +1,214 @@
+defmodule Sentry.MetricsIntegrationTest do
+ use Sentry.Case, async: false
+
+ import Sentry.TestHelpers
+
+ alias Sentry.{Metrics, TelemetryProcessor}
+ alias Sentry.Telemetry.Buffer
+
+ setup context do
+ bypass = Bypass.open()
+ test_pid = self()
+ ref = make_ref()
+
+ Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn ->
+ {:ok, body, conn} = Plug.Conn.read_body(conn)
+ send(test_pid, {ref, body})
+ Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>)
+ end)
+
+ stop_supervised!(context.telemetry_processor)
+
+ uid = System.unique_integer([:positive])
+ processor_name = :"test_metric_integration_#{uid}"
+
+ start_supervised!(
+ {TelemetryProcessor, name: processor_name, buffer_configs: %{metric: %{batch_size: 1}}},
+ id: processor_name
+ )
+
+ Process.put(:sentry_telemetry_processor, processor_name)
+ put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1", enable_metrics: true)
+
+ %{processor: processor_name, ref: ref, bypass: bypass}
+ end
+
+ describe "metric batching" do
+ test "sends metric events as batched envelopes", ctx do
+ Metrics.count("metric.1", 1)
+ Metrics.gauge("metric.2", 100)
+
+ bodies = collect_envelope_bodies(ctx.ref, 2)
+ items = Enum.map(bodies, &decode_envelope!/1)
+ assert length(items) == 2
+
+ for [{header, payload}] <- items do
+ assert header["type"] == "trace_metric"
+ assert %{"items" => [%{"type" => _}]} = payload
+ end
+ end
+
+ test "flush drains metric buffer completely", ctx do
+ # Use buffered mode
+ put_test_config(send_result: :none)
+
+ scheduler = TelemetryProcessor.get_scheduler(ctx.processor)
+ :sys.suspend(scheduler)
+
+ Metrics.count("flush.1", 1)
+ Metrics.count("flush.2", 2)
+ Metrics.count("flush.3", 3)
+
+ buffer = TelemetryProcessor.get_buffer(ctx.processor, :metric)
+ assert Buffer.size(buffer) == 3
+
+ :sys.resume(scheduler)
+ :ok = TelemetryProcessor.flush(ctx.processor)
+
+ assert Buffer.size(buffer) == 0
+
+ bodies = collect_envelope_bodies(ctx.ref, 3)
+ assert length(bodies) == 3
+ end
+
+ test "applies before_send_metric callback", ctx do
+ put_test_config(
+ before_send_metric: fn metric ->
+ if metric.value < 10, do: nil, else: metric
+ end
+ )
+
+ Metrics.count("keep.me", 15)
+ Metrics.count("drop.me", 5)
+
+ bodies = collect_envelope_bodies(ctx.ref, 1)
+ assert length(bodies) == 1
+
+ [items] = Enum.map(bodies, &decode_envelope!/1)
+
+ assert [
+ {%{"type" => "trace_metric"},
+ %{"items" => [%{"name" => "keep.me", "value" => 15}]}}
+ ] =
+ items
+
+ # The dropped metric should not produce an envelope
+ ref = ctx.ref
+ refute_receive {^ref, _body}, 200
+ end
+
+ test "callback can modify metrics before sending", ctx do
+ put_test_config(
+ before_send_metric: fn metric ->
+ %{metric | value: metric.value * 2}
+ end
+ )
+
+ Metrics.count("test.metric", 5)
+
+ bodies = collect_envelope_bodies(ctx.ref, 1)
+ [items] = Enum.map(bodies, &decode_envelope!/1)
+ [{%{"type" => "trace_metric"}, %{"items" => [metric]}}] = items
+ assert metric["value"] == 10
+ end
+ end
+
+ describe "metric envelope format" do
+ test "metrics include all required fields", ctx do
+ Metrics.count("test.counter", 42, unit: "request", attributes: %{method: "GET"})
+
+ bodies = collect_envelope_bodies(ctx.ref, 1)
+ [items] = Enum.map(bodies, &decode_envelope!/1)
+ [{header, payload}] = items
+
+ assert header["type"] == "trace_metric"
+
+ assert header["content_type"] == "application/vnd.sentry.items.trace-metric+json"
+
+ %{"items" => [metric]} = payload
+ assert metric["type"] == "counter"
+ assert metric["name"] == "test.counter"
+ assert metric["value"] == 42
+ assert metric["unit"] == "request"
+ assert metric["timestamp"]
+
+ # Check default attributes
+ attrs = metric["attributes"]
+ assert attrs["sentry.sdk.name"]["value"] == "sentry.elixir"
+ assert attrs["method"]["value"] == "GET"
+ end
+
+ test "metrics include environment and release", ctx do
+ put_test_config(environment_name: "production", release: "1.0.0")
+
+ Metrics.gauge("memory.usage", 1024)
+
+ bodies = collect_envelope_bodies(ctx.ref, 1)
+ [items] = Enum.map(bodies, &decode_envelope!/1)
+ [{_header, %{"items" => [metric]}}] = items
+
+ attrs = metric["attributes"]
+ assert attrs["sentry.environment"]["value"] == "production"
+ assert attrs["sentry.release"]["value"] == "1.0.0"
+ end
+
+ test "all three metric types are supported", ctx do
+ Metrics.count("counter.metric", 1)
+ Metrics.gauge("gauge.metric", 100)
+ Metrics.distribution("distribution.metric", 3.14)
+
+ bodies = collect_envelope_bodies(ctx.ref, 3)
+ items = Enum.flat_map(bodies, &decode_envelope!/1)
+
+ types = Enum.map(items, fn {_header, %{"items" => [metric]}} -> metric["type"] end)
+ assert "counter" in types
+ assert "gauge" in types
+ assert "distribution" in types
+ end
+ end
+
+ describe "sync mode" do
+ setup do
+ put_test_config(send_result: :sync)
+ :ok
+ end
+
+ test "metrics sent directly via transport in sync mode", ctx do
+ flush_ref_messages(ctx.ref)
+
+ Metrics.count("sync.metric", 42)
+
+ bodies = collect_envelope_bodies(ctx.ref, 1)
+ assert length(bodies) == 1
+
+ [items] = Enum.map(bodies, &decode_envelope!/1)
+ [{_header, %{"items" => [metric]}}] = items
+ assert metric["name"] == "sync.metric"
+ assert metric["value"] == 42
+ end
+ end
+
+ # Helper functions
+
+ defp flush_ref_messages(ref) do
+ receive do
+ {^ref, _body} -> flush_ref_messages(ref)
+ after
+ 100 -> :ok
+ end
+ end
+
+ defp collect_envelope_bodies(ref, expected_count) do
+ collect_envelope_bodies(ref, expected_count, [])
+ end
+
+ defp collect_envelope_bodies(_ref, 0, acc), do: Enum.reverse(acc)
+
+ defp collect_envelope_bodies(ref, remaining, acc) do
+ receive do
+ {^ref, body} -> collect_envelope_bodies(ref, remaining - 1, [body | acc])
+ after
+ 2000 -> Enum.reverse(acc)
+ end
+ end
+end
diff --git a/test/sentry/metrics_test.exs b/test/sentry/metrics_test.exs
new file mode 100644
index 00000000..11dc581f
--- /dev/null
+++ b/test/sentry/metrics_test.exs
@@ -0,0 +1,310 @@
+defmodule Sentry.MetricsTest do
+ use Sentry.Case, async: true
+
+ import Sentry.TestHelpers
+
+ alias Sentry.{Metric, Metrics}
+
+ setup do
+ put_test_config(dsn: nil)
+ :ok
+ end
+
+ describe "count/2" do
+ test "creates a counter metric with default options" do
+ put_test_config(enable_metrics: true)
+
+ assert :ok = Metrics.count("button.clicks", 1)
+ end
+
+ test "creates a counter metric with unit" do
+ put_test_config(enable_metrics: true)
+
+ assert :ok = Metrics.count("http.requests", 5, unit: "request")
+ end
+
+ test "creates a counter metric with attributes" do
+ put_test_config(enable_metrics: true)
+
+ assert :ok =
+ Metrics.count("button.clicks", 1,
+ unit: "click",
+ attributes: %{button_id: "submit"}
+ )
+ end
+
+ test "respects enable_metrics kill switch when false" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:metric_sent, metric})
+ metric
+ end
+
+ put_test_config(enable_metrics: false, before_send_metric: callback)
+
+ # Should not raise error, just silently return :ok
+ assert :ok = Metrics.count("button.clicks", 1)
+
+ # Verify the metric was NOT sent and callback was NOT called
+ refute_receive {:metric_sent, _}, 100
+ end
+
+ test "applies before_send_metric callback" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:callback_called, metric})
+ metric
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ Metrics.count("test.counter", 42, unit: "item")
+
+ assert_receive {:callback_called, %Metric{} = metric}
+ assert metric.type == :counter
+ assert metric.name == "test.counter"
+ assert metric.value == 42
+ assert metric.unit == "item"
+ end
+
+ test "default attributes are available to before_send_metric callback" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:callback_with_attrs, metric.attributes})
+ metric
+ end
+
+ put_test_config(
+ enable_metrics: true,
+ before_send_metric: callback,
+ environment_name: "test",
+ release: "1.0.0"
+ )
+
+ Metrics.count("test.counter", 1)
+
+ assert_receive {:callback_with_attrs, attrs}
+ # Verify default attributes are present before callback
+ assert attrs["sentry.sdk.name"] == "sentry.elixir"
+ assert attrs["sentry.environment"] == "test"
+ assert attrs["sentry.release"] == "1.0.0"
+ assert is_binary(attrs["sentry.sdk.version"])
+ end
+
+ test "filters metric when before_send_metric returns nil" do
+ callback = fn _metric -> nil end
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ # Should not crash, just skip sending
+ assert :ok = Metrics.count("test.counter", 1)
+ end
+
+ test "filters metric when before_send_metric returns false" do
+ callback = fn _metric -> false end
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ # Should not crash, just skip sending
+ assert :ok = Metrics.count("test.counter", 1)
+ end
+
+ test "allows before_send_metric to modify metric" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:original_value, metric.value})
+ %{metric | value: metric.value * 2}
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ Metrics.count("test.counter", 5)
+
+ assert_receive {:original_value, 5}
+ end
+
+ test "handles before_send_metric callback as {module, function} tuple" do
+ defmodule TestCallback do
+ def filter_metric(metric) do
+ if metric.value > 10, do: metric, else: nil
+ end
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: {TestCallback, :filter_metric})
+
+ # Should be filtered out
+ assert :ok = Metrics.count("test.counter", 5)
+
+ # Should pass through
+ assert :ok = Metrics.count("test.counter", 15)
+ end
+
+ test "always includes trace_id even without active span" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:trace_id, metric.trace_id})
+ metric
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ Metrics.count("test.counter", 1)
+
+ assert_receive {:trace_id, trace_id}
+ # trace_id is REQUIRED per spec, should never be nil
+ assert is_binary(trace_id)
+ assert String.length(trace_id) == 32
+ end
+ end
+
+ describe "gauge/2" do
+ test "creates a gauge metric with default options" do
+ put_test_config(enable_metrics: true)
+
+ assert :ok = Metrics.gauge("memory.usage", 1024)
+ end
+
+ test "creates a gauge metric with unit and attributes" do
+ put_test_config(enable_metrics: true)
+
+ assert :ok =
+ Metrics.gauge("active.connections", 42,
+ unit: "connection",
+ attributes: %{pool: "main"}
+ )
+ end
+
+ test "respects enable_metrics kill switch" do
+ put_test_config(enable_metrics: false)
+
+ assert :ok = Metrics.gauge("memory.usage", 1024)
+ end
+ end
+
+ describe "distribution/2" do
+ test "creates a distribution metric with default options" do
+ put_test_config(enable_metrics: true)
+
+ assert :ok = Metrics.distribution("response.time", 42.5)
+ end
+
+ test "creates a distribution metric with unit and attributes" do
+ put_test_config(enable_metrics: true)
+
+ assert :ok =
+ Metrics.distribution("response.time", 42.5,
+ unit: "millisecond",
+ attributes: %{endpoint: "/api"}
+ )
+ end
+
+ test "respects enable_metrics kill switch" do
+ put_test_config(enable_metrics: false)
+
+ assert :ok = Metrics.distribution("response.time", 42.5)
+ end
+ end
+
+ describe "trace context extraction" do
+ test "metrics always have trace_id (REQUIRED per spec)" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:metric, metric})
+ metric
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ Metrics.count("test.counter", 1)
+
+ assert_receive {:metric, %Metric{} = metric}
+ # trace_id is REQUIRED per spec - should always be present, even without active span
+ assert is_binary(metric.trace_id)
+ assert String.length(metric.trace_id) == 32
+ # span_id is optional - nil when no active span
+ assert metric.span_id == nil
+ end
+ end
+
+ describe "before_send_metric error handling" do
+ test "returns original metric when callback raises" do
+ callback = fn _metric ->
+ raise "callback error"
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ import ExUnit.CaptureLog
+
+ log =
+ capture_log(fn ->
+ assert :ok = Metrics.count("test.counter", 42)
+ end)
+
+ assert log =~ "before_send_metric callback failed"
+ assert log =~ "callback error"
+ end
+
+ test "drops metric when callback returns invalid type" do
+ test_pid = self()
+
+ callback = fn _metric ->
+ send(test_pid, :callback_called)
+ :invalid_return
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ assert :ok = Metrics.count("test.counter", 1)
+ assert_receive :callback_called
+ end
+ end
+
+ describe "edge case values" do
+ test "accepts zero value" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:metric, metric})
+ metric
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ assert :ok = Metrics.count("test.zero", 0)
+ assert_receive {:metric, %Metric{value: 0}}
+ end
+
+ test "accepts negative values" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:metric, metric})
+ metric
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ assert :ok = Metrics.gauge("test.negative", -42)
+ assert_receive {:metric, %Metric{value: -42}}
+ end
+
+ test "accepts float values" do
+ test_pid = self()
+
+ callback = fn metric ->
+ send(test_pid, {:metric, metric})
+ metric
+ end
+
+ put_test_config(enable_metrics: true, before_send_metric: callback)
+
+ assert :ok = Metrics.distribution("test.float", 0.001)
+ assert_receive {:metric, %Metric{value: 0.001}}
+ end
+ end
+end
diff --git a/test/sentry/telemetry/scheduler_test.exs b/test/sentry/telemetry/scheduler_test.exs
index 6ea130d6..95dc13e2 100644
--- a/test/sentry/telemetry/scheduler_test.exs
+++ b/test/sentry/telemetry/scheduler_test.exs
@@ -20,16 +20,33 @@ defmodule Sentry.Telemetry.SchedulerTest do
cycle = Scheduler.build_priority_cycle()
# Default weights: critical=5, high=4, medium=3, low=2
- assert length(cycle) == 14
- assert Enum.frequencies(cycle) == %{error: 5, check_in: 4, transaction: 3, log: 2}
+ # Categories: error=5, check_in=4, transaction=3, log=2, metric=2
+ assert length(cycle) == 16
+
+ assert Enum.frequencies(cycle) == %{
+ error: 5,
+ check_in: 4,
+ transaction: 3,
+ log: 2,
+ metric: 2
+ }
end
test "builds cycle with custom weights" do
custom_weights = %{low: 5}
cycle = Scheduler.build_priority_cycle(custom_weights)
- assert length(cycle) == 17
- assert Enum.frequencies(cycle) == %{error: 5, check_in: 4, transaction: 3, log: 5}
+ # Custom: low=5, others default (critical=5, high=4, medium=3)
+ # Categories: error=5, check_in=4, transaction=3, log=5, metric=5
+ assert length(cycle) == 22
+
+ assert Enum.frequencies(cycle) == %{
+ error: 5,
+ check_in: 4,
+ transaction: 3,
+ log: 5,
+ metric: 5
+ }
end
end
diff --git a/test/sentry/telemetry_processor_integration_test.exs b/test/sentry/telemetry_processor_integration_test.exs
index a0925c3c..bd21e27c 100644
--- a/test/sentry/telemetry_processor_integration_test.exs
+++ b/test/sentry/telemetry_processor_integration_test.exs
@@ -5,7 +5,7 @@ defmodule Sentry.TelemetryProcessorIntegrationTest do
alias Sentry.TelemetryProcessor
alias Sentry.Telemetry.Buffer
- alias Sentry.{LogEvent, Transaction}
+ alias Sentry.{LogEvent, Metric, Transaction}
setup context do
bypass = Bypass.open()
@@ -412,6 +412,7 @@ defmodule Sentry.TelemetryProcessorIntegrationTest do
:ets.delete(rate_limiter_table, "error")
:ets.delete(rate_limiter_table, "monitor")
:ets.delete(rate_limiter_table, "transaction")
+ :ets.delete(rate_limiter_table, "trace_metric")
catch
:error, :badarg -> :ok
end
@@ -505,6 +506,23 @@ defmodule Sentry.TelemetryProcessorIntegrationTest do
assert Buffer.size(transaction_buffer) == 0
end
+
+ test "drops rate-limited metric events before they enter the buffer", ctx do
+ Bypass.stub(ctx.bypass, "POST", "/api/1/envelope/", fn conn ->
+ Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>)
+ end)
+
+ put_test_config(telemetry_processor_categories: [:metric, :log])
+
+ metric_buffer = TelemetryProcessor.get_buffer(ctx.processor, :metric)
+
+ :ets.insert(ctx.rate_limiter_table, {"trace_metric", System.system_time(:second) + 60})
+
+ assert {:ok, {:rate_limited, "trace_metric"}} =
+ TelemetryProcessor.add(ctx.processor, make_metric("pre-buffer-drop", 1))
+
+ assert Buffer.size(metric_buffer) == 0
+ end
end
defp make_transaction do
@@ -519,6 +537,16 @@ defmodule Sentry.TelemetryProcessorIntegrationTest do
}
end
+ defp make_metric(name, value) do
+ %Metric{
+ type: :counter,
+ name: name,
+ value: value,
+ timestamp: System.system_time(:nanosecond) / 1_000_000_000,
+ attributes: %{}
+ }
+ end
+
defp flush_ref_messages(ref) do
receive do
{^ref, _body} -> flush_ref_messages(ref)
diff --git a/test/sentry/telemetry_processor_test.exs b/test/sentry/telemetry_processor_test.exs
index beaa4256..1000ce89 100644
--- a/test/sentry/telemetry_processor_test.exs
+++ b/test/sentry/telemetry_processor_test.exs
@@ -19,8 +19,8 @@ defmodule Sentry.TelemetryProcessorTest do
assert Process.alive?(pid)
children = Supervisor.which_children(pid)
- # 4 buffers (error, check_in, transaction, log) + Scheduler = 5
- assert length(children) == 5
+ # 5 buffers (error, check_in, transaction, log, metric) + Scheduler = 6
+ assert length(children) == 6
Supervisor.stop(pid)
end
diff --git a/test_integrations/phoenix_app/config/test.exs b/test_integrations/phoenix_app/config/test.exs
index 9c7997b5..edf9f311 100644
--- a/test_integrations/phoenix_app/config/test.exs
+++ b/test_integrations/phoenix_app/config/test.exs
@@ -32,8 +32,8 @@ config :phoenix_live_view,
enable_expensive_runtime_checks: true
config :sentry,
- dsn: "https://public@sentry.example.com/1",
- environment_name: :dev,
+ dsn: nil,
+ environment_name: :test,
enable_source_code_context: true,
root_source_code_paths: [File.cwd!()],
test_mode: true,