From 5ed357bca135aa0d5b659b934fc2729c5e32c961 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 24 Sep 2026 11:17:49 +0000 Subject: [PATCH] feat(metrics): attach sentry.is_localhost to metrics --- lib/sentry/metric.ex | 39 ++++++++++++++++++++- test/sentry/metrics_integration_test.exs | 43 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/lib/sentry/metric.ex b/lib/sentry/metric.ex index f826a54f..74f82b68 100644 --- a/lib/sentry/metric.ex +++ b/lib/sentry/metric.ex @@ -49,12 +49,22 @@ defmodule Sentry.Metric do 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). + + It also adds `"sentry.is_localhost"`: `true` when the calling process is serving a + request whose host is `localhost`, a `*.localhost` name, or a loopback IP address + (`127.0.0.0/8` or `::1`), and `false` otherwise, including when the process is not + serving a request. The request is the one recorded in the process's + `Sentry.Context` request context, as set by `Sentry.PlugContext`. A value of the + same name in the metric's attributes takes precedence. + + *Changed in 14.0.0:* adds `"sentry.is_localhost"`. """ @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 + "sentry.sdk.version" => @sdk_version, + "sentry.is_localhost" => serving_localhost_request?() } # Add optional attributes if configured @@ -83,6 +93,33 @@ defmodule Sentry.Metric do :atomics.add_get(:persistent_term.get(@sequence_key), 1, 1) - 1 end + defp serving_localhost_request? do + case request_host(Sentry.Context.get_all().request) do + host when is_binary(host) -> localhost?(String.downcase(host)) + nil -> false + end + end + + defp request_host(%{env: %{"SERVER_NAME" => host}}) when is_binary(host), do: host + defp request_host(%{url: url}) when is_binary(url), do: URI.parse(url).host + defp request_host(_request), do: nil + + defp localhost?("localhost"), do: true + + defp localhost?(host) do + String.ends_with?(host, ".localhost") or loopback_address?(host) + end + + defp loopback_address?(host) do + unbracketed_host = host |> String.trim_leading("[") |> String.trim_trailing("]") + + case :inet.parse_address(to_charlist(unbracketed_host)) do + {:ok, {127, _, _, _}} -> true + {:ok, {0, 0, 0, 0, 0, 0, 0, 1}} -> true + _ -> false + end + end + defp maybe_put_attr(attrs, _key, nil), do: attrs defp maybe_put_attr(attrs, key, value), do: Map.put(attrs, key, value) diff --git a/test/sentry/metrics_integration_test.exs b/test/sentry/metrics_integration_test.exs index 6e75109e..dfe143d5 100644 --- a/test/sentry/metrics_integration_test.exs +++ b/test/sentry/metrics_integration_test.exs @@ -175,6 +175,38 @@ defmodule Sentry.MetricsIntegrationTest do end end + describe "sentry.is_localhost attribute" do + test "is true for a metric recorded while serving a localhost request", ctx do + serve_request("http://localhost:4000/checkout") + + Metrics.count("orders.placed", 1) + + assert is_localhost_attribute(ctx.ref) == %{"type" => "boolean", "value" => true} + end + + test "is true for a metric recorded while serving a request to a loopback address", ctx do + serve_request("http://[::1]:4000/checkout") + + Metrics.count("orders.placed", 1) + + assert is_localhost_attribute(ctx.ref) == %{"type" => "boolean", "value" => true} + end + + test "is false for a metric recorded while serving a request to a public host", ctx do + serve_request("https://shop.example.com/checkout") + + Metrics.count("orders.placed", 1) + + assert is_localhost_attribute(ctx.ref) == %{"type" => "boolean", "value" => false} + end + + test "is false for a metric recorded outside of a request", ctx do + Metrics.count("orders.placed", 1) + + assert is_localhost_attribute(ctx.ref) == %{"type" => "boolean", "value" => false} + end + end + describe "trace context on recorded metrics" do test "records the metric with the trace of the surrounding span", ctx do put_test_config(traces_sample_rate: 1.0) @@ -193,6 +225,17 @@ defmodule Sentry.MetricsIntegrationTest do end end + defp serve_request(url) do + :get + |> Plug.Test.conn(url) + |> Sentry.PlugContext.call(Sentry.PlugContext.init([])) + end + + defp is_localhost_attribute(ref) do + [[{_header, %{"items" => [metric]}}]] = collect_envelopes(ref, 1) + metric["attributes"]["sentry.is_localhost"] + end + defp assert_metric_dropped(ctx, crashing_callback) do put_test_config(before_send_metric: crashing_callback)