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,