From 55cacc1b344f2ff380993e28bd50b116edc38d88 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 26 May 2025 11:46:11 +0000 Subject: [PATCH 1/5] Revert "Revert "Add SpanProcessor for OpenTelemetry (#875)"" This reverts commit 2ced90ef993ac2aae3ffd49eaa27c485827fce51. --- config/config.exs | 8 +- lib/sentry/application.ex | 8 + lib/sentry/client.ex | 2 +- lib/sentry/config.ex | 29 ++ lib/sentry/opentelemetry/sampler.ex | 31 ++ lib/sentry/opentelemetry/span_processor.ex | 193 +++++++ lib/sentry/opentelemetry/span_record.ex | 76 +++ lib/sentry/opentelemetry/span_storage.ex | 139 +++++ mix.exs | 11 +- mix.lock | 11 + test/event_test.exs | 2 +- test/sentry/config_test.exs | 9 + test/sentry/opentelemetry/sampler_test.exs | 19 + .../opentelemetry/span_processor_test.exs | 123 +++++ .../opentelemetry/span_storage_test.exs | 489 ++++++++++++++++++ test/support/case.ex | 19 +- test_integrations/phoenix_app/config/test.exs | 2 + test_integrations/phoenix_app/mix.exs | 7 +- test_integrations/phoenix_app/mix.lock | 11 + test_integrations/umbrella/mix.lock | 5 +- 20 files changed, 1185 insertions(+), 9 deletions(-) create mode 100644 lib/sentry/opentelemetry/sampler.ex create mode 100644 lib/sentry/opentelemetry/span_processor.ex create mode 100644 lib/sentry/opentelemetry/span_record.ex create mode 100644 lib/sentry/opentelemetry/span_storage.ex create mode 100644 test/sentry/opentelemetry/sampler_test.exs create mode 100644 test/sentry/opentelemetry/span_processor_test.exs create mode 100644 test/sentry/opentelemetry/span_storage_test.exs diff --git a/config/config.exs b/config/config.exs index d63cc9d2..324ae9f7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -10,9 +10,15 @@ if config_env() == :test do send_result: :sync, send_max_attempts: 1, dedup_events: false, - test_mode: true + test_mode: true, + traces_sample_rate: 1.0 config :logger, backends: [] + + config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} + + config :opentelemetry, + sampler: {Sentry.OpenTelemetry.Sampler, [drop: ["Elixir.Oban.Stager process"]]} end config :phoenix, :json_library, if(Code.ensure_loaded?(JSON), do: JSON, else: Jason) diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 7c06be7f..0d8d6058 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -26,6 +26,13 @@ defmodule Sentry.Application do integrations_config = Config.integrations() + maybe_span_storage = + if Config.tracing?() do + [Sentry.OpenTelemetry.SpanStorage] + else + [] + end + children = [ {Registry, keys: :unique, name: Sentry.Transport.SenderRegistry}, @@ -39,6 +46,7 @@ defmodule Sentry.Application do ]} ] ++ maybe_http_client_spec ++ + maybe_span_storage ++ [Sentry.Transport.SenderPool] cache_loaded_applications() diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 67ec08b2..f2c0ebb2 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -117,7 +117,7 @@ defmodule Sentry.Client do result_type = Keyword.get_lazy(opts, :result, &Config.send_result/0) client = Keyword.get_lazy(opts, :client, &Config.client/0) - sample_rate = Keyword.get_lazy(opts, :sample_rate, &Config.sample_rate/0) + sample_rate = Keyword.get_lazy(opts, :sample_rate, &Config.traces_sample_rate/0) before_send = Keyword.get_lazy(opts, :before_send, &Config.before_send/0) after_send_event = Keyword.get_lazy(opts, :after_send_event, &Config.after_send_event/0) diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index 39221f42..f07777dd 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -143,6 +143,20 @@ defmodule Sentry.Config do be used as the value for this option. """ ], + traces_sample_rate: [ + type: {:custom, __MODULE__, :__validate_traces_sample_rate__, []}, + default: 0.0, + doc: """ + The sample rate for transaction events. A value between `0.0` and `1.0` (inclusive). + A value of `0.0` means no transactions will be sampled, while `1.0` means all transactions + will be sampled. This value is also used to determine if tracing is enabled: if it's + greater than `0`, tracing is enabled. + + Tracing requires OpenTelemetry packages to work. See [the + OpenTelemetry setup documentation](https://opentelemetry.io/docs/languages/erlang/getting-started/) + for guides on how to set it up. + """ + ], included_environments: [ type: {:or, [{:in, [:all]}, {:list, {:or, [:atom, :string]}}]}, deprecated: "Use :dsn to control whether to send events to Sentry.", @@ -607,6 +621,9 @@ defmodule Sentry.Config do @spec sample_rate() :: float() def sample_rate, do: fetch!(:sample_rate) + @spec traces_sample_rate() :: float() + def traces_sample_rate, do: fetch!(:traces_sample_rate) + @spec hackney_opts() :: keyword() def hackney_opts, do: fetch!(:hackney_opts) @@ -644,6 +661,9 @@ defmodule Sentry.Config do @spec integrations() :: keyword() def integrations, do: fetch!(:integrations) + @spec tracing?() :: boolean() + def tracing?, do: fetch!(:traces_sample_rate) > 0.0 + @spec put_config(atom(), term()) :: :ok def put_config(key, value) when is_atom(key) do unless key in @valid_keys do @@ -743,6 +763,15 @@ defmodule Sentry.Config do end end + def __validate_traces_sample_rate__(float) do + if is_float(float) and float >= 0.0 and float <= 1.0 do + {:ok, float} + else + {:error, + "expected :traces_sample_rate to be a float between 0.0 and 1.0 (included), got: #{inspect(float)}"} + end + end + def __validate_json_library__(nil) do {:error, "nil is not a valid value for the :json_library option"} end diff --git a/lib/sentry/opentelemetry/sampler.ex b/lib/sentry/opentelemetry/sampler.ex new file mode 100644 index 00000000..18fa5511 --- /dev/null +++ b/lib/sentry/opentelemetry/sampler.ex @@ -0,0 +1,31 @@ +if Code.ensure_loaded?(:otel_sampler) do + defmodule Sentry.OpenTelemetry.Sampler do + @moduledoc false + + @behaviour :otel_sampler + + def setup(config) do + config + end + + def description(_) do + "SentrySampler" + end + + def should_sample( + _ctx, + _trace_id, + _links, + span_name, + _span_kind, + _attributes, + config + ) do + if span_name in config[:drop] do + {:drop, [], []} + else + {:record_and_sample, [], []} + end + end + end +end diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex new file mode 100644 index 00000000..5ee8fb22 --- /dev/null +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -0,0 +1,193 @@ +if Code.ensure_loaded?(OpenTelemetry) do + defmodule Sentry.OpenTelemetry.SpanProcessor do + @moduledoc false + + @behaviour :otel_span_processor + + require OpenTelemetry.SemConv.ClientAttributes, as: ClientAttributes + require OpenTelemetry.SemConv.Incubating.DBAttributes, as: DBAttributes + require OpenTelemetry.SemConv.Incubating.HTTPAttributes, as: HTTPAttributes + require OpenTelemetry.SemConv.Incubating.URLAttributes, as: URLAttributes + require OpenTelemetry.SemConv.Incubating.MessagingAttributes, as: MessagingAttributes + + require Logger + + alias Sentry.{Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord} + alias Sentry.Interfaces.Span + + # This can be a no-op since we can postpone inserting the span into storage until on_end + @impl :otel_span_processor + def on_start(_ctx, otel_span, _config) do + otel_span + end + + @impl :otel_span_processor + def on_end(otel_span, _config) do + span_record = SpanRecord.new(otel_span) + + SpanStorage.store_span(span_record) + + if span_record.parent_span_id == nil do + child_span_records = SpanStorage.get_child_spans(span_record.span_id) + transaction = build_transaction(span_record, child_span_records) + + result = + case Sentry.send_transaction(transaction) do + {:ok, _id} -> + true + + :ignored -> + true + + {:error, error} -> + Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}") + {:error, :invalid_span} + end + + :ok = SpanStorage.remove_root_span(span_record.span_id) + + result + else + true + end + end + + @impl :otel_span_processor + def force_flush(_config) do + :ok + end + + defp build_transaction(root_span_record, child_span_records) do + root_span = build_span(root_span_record) + child_spans = Enum.map(child_span_records, &build_span(&1)) + + Transaction.new(%{ + span_id: root_span.span_id, + transaction: transaction_name(root_span_record), + transaction_info: %{source: :custom}, + start_timestamp: root_span_record.start_time, + timestamp: root_span_record.end_time, + contexts: %{ + trace: build_trace_context(root_span_record) + }, + spans: child_spans + }) + end + + defp transaction_name( + %{attributes: %{unquote(to_string(MessagingAttributes.messaging_system())) => :oban}} = + span_record + ) do + span_record.attributes["oban.job.worker"] + end + + defp transaction_name(span_record), do: span_record.name + + defp build_trace_context(span_record) do + {op, description} = get_op_description(span_record) + + %{ + trace_id: span_record.trace_id, + span_id: span_record.span_id, + parent_span_id: span_record.parent_span_id, + op: op, + description: description, + origin: span_record.origin, + data: span_record.attributes + } + end + + defp get_op_description( + %{ + attributes: %{ + unquote(to_string(HTTPAttributes.http_request_method())) => http_request_method + } + } = span_record + ) do + op = "http.#{span_record.kind}" + + client_address = + Map.get(span_record.attributes, to_string(ClientAttributes.client_address())) + + url_path = Map.get(span_record.attributes, to_string(URLAttributes.url_path())) + + description = + to_string(http_request_method) <> + ((client_address && " from #{client_address}") || "") <> + ((url_path && " #{url_path}") || "") + + {op, description} + end + + defp get_op_description( + %{attributes: %{unquote(to_string(DBAttributes.db_system())) => _db_system}} = + span_record + ) do + db_query_text = Map.get(span_record.attributes, "db.statement") + + {"db", db_query_text} + end + + defp get_op_description(%{ + attributes: + %{unquote(to_string(MessagingAttributes.messaging_system())) => :oban} = attributes + }) do + {"queue.process", attributes["oban.job.worker"]} + end + + defp get_op_description(span_record) do + {span_record.name, span_record.name} + end + + defp build_span(span_record) do + {op, description} = get_op_description(span_record) + + %Span{ + op: op, + description: description, + start_timestamp: span_record.start_time, + timestamp: span_record.end_time, + trace_id: span_record.trace_id, + span_id: span_record.span_id, + parent_span_id: span_record.parent_span_id, + origin: span_record.origin, + data: Map.put(span_record.attributes, "otel.kind", span_record.kind), + status: span_status(span_record) + } + end + + defp span_status(%{ + attributes: %{ + unquote(to_string(HTTPAttributes.http_response_status_code())) => + http_response_status_code + } + }) do + to_status(http_response_status_code) + end + + defp span_status(_span_record), do: nil + + # WebSocket upgrade spans doesn't have a HTTP status + defp to_status(nil), do: nil + + defp to_status(status) when status in 200..299, do: "ok" + + for {status, string} <- %{ + 400 => "invalid_argument", + 401 => "unauthenticated", + 403 => "permission_denied", + 404 => "not_found", + 409 => "already_exists", + 429 => "resource_exhausted", + 499 => "cancelled", + 500 => "internal_error", + 501 => "unimplemented", + 503 => "unavailable", + 504 => "deadline_exceeded" + } do + defp to_status(unquote(status)), do: unquote(string) + end + + defp to_status(_any), do: "unknown_error" + end +end diff --git a/lib/sentry/opentelemetry/span_record.ex b/lib/sentry/opentelemetry/span_record.ex new file mode 100644 index 00000000..5d02c556 --- /dev/null +++ b/lib/sentry/opentelemetry/span_record.ex @@ -0,0 +1,76 @@ +if Code.ensure_loaded?(OpenTelemetry) do + defmodule Sentry.OpenTelemetry.SpanRecord do + @moduledoc false + + @type t :: %__MODULE__{} + + require Record + require OpenTelemetry + + @fields Record.extract(:span, from_lib: "opentelemetry/include/otel_span.hrl") + Record.defrecordp(:span, @fields) + + defstruct @fields ++ [:origin] + + def new(span() = otel_span) do + otel_attrs = span(otel_span) + + {:attributes, _, _, _, attributes} = otel_attrs[:attributes] + + origin = + case otel_attrs[:instrumentation_scope] do + {:instrumentation_scope, origin, _version, _} -> + origin + + _ -> + :undefined + end + + attrs = + otel_attrs + |> Keyword.delete(:attributes) + |> Keyword.merge( + trace_id: cast_trace_id(otel_attrs[:trace_id]), + span_id: cast_span_id(otel_attrs[:span_id]), + parent_span_id: cast_span_id(otel_attrs[:parent_span_id]), + origin: origin, + start_time: cast_timestamp(otel_attrs[:start_time]), + end_time: cast_timestamp(otel_attrs[:end_time]), + attributes: normalize_attributes(attributes) + ) + |> Map.new() + + struct(__MODULE__, attrs) + end + + defp normalize_attributes(attributes) do + Enum.map(attributes, fn {key, value} -> + {to_string(key), value} + end) + |> Map.new() + end + + defp cast_span_id(nil), do: nil + defp cast_span_id(:undefined), do: nil + defp cast_span_id(span_id), do: bytes_to_hex(span_id, 16) + + defp cast_trace_id(trace_id), do: bytes_to_hex(trace_id, 32) + + defp cast_timestamp(:undefined), do: nil + defp cast_timestamp(nil), do: nil + + defp cast_timestamp(timestamp) do + nano_timestamp = OpenTelemetry.timestamp_to_nano(timestamp) + {:ok, datetime} = DateTime.from_unix(div(nano_timestamp, 1_000_000), :millisecond) + + DateTime.to_iso8601(datetime) + end + + defp bytes_to_hex(bytes, length) do + case(:otel_utils.format_binary_string("~#{length}.16.0b", [bytes])) do + {:ok, result} -> result + {:error, _} -> raise "Failed to convert bytes to hex: #{inspect(bytes)}" + end + end + end +end diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex new file mode 100644 index 00000000..30095a5d --- /dev/null +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -0,0 +1,139 @@ +defmodule Sentry.OpenTelemetry.SpanStorage do + @moduledoc false + use GenServer + + defstruct [:cleanup_interval, :table_name] + + alias Sentry.OpenTelemetry.SpanRecord + + @cleanup_interval :timer.minutes(5) + + @span_ttl 30 * 60 + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts) when is_list(opts) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @impl true + def init(opts) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + cleanup_interval = Keyword.get(opts, :cleanup_interval, @cleanup_interval) + + _ = :ets.new(table_name, [:named_table, :public, :ordered_set]) + + schedule_cleanup(cleanup_interval) + + {:ok, %__MODULE__{cleanup_interval: cleanup_interval, table_name: table_name}} + end + + @impl true + def handle_info(:cleanup_stale_spans, state) do + cleanup_stale_spans(state.table_name) + schedule_cleanup(state.cleanup_interval) + + {:noreply, state} + end + + @spec store_span(SpanRecord.t(), keyword()) :: true + def store_span(span_data, opts \\ []) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + stored_at = System.system_time(:second) + + if span_data.parent_span_id == nil do + :ets.insert(table_name, {{:root_span, span_data.span_id}, span_data, stored_at}) + else + key = {:child_span, span_data.parent_span_id, span_data.span_id} + + :ets.insert(table_name, {key, span_data, stored_at}) + end + end + + @spec get_root_span(String.t(), keyword()) :: SpanRecord.t() | nil + def get_root_span(span_id, opts \\ []) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + + case :ets.lookup(table_name, {:root_span, span_id}) do + [{{:root_span, ^span_id}, span, _stored_at}] -> span + [] -> nil + end + end + + @spec get_child_spans(String.t(), keyword()) :: [SpanRecord.t()] + def get_child_spans(parent_span_id, opts \\ []) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + + :ets.match_object(table_name, {{:child_span, parent_span_id, :_}, :_, :_}) + |> Enum.map(fn {_key, span_data, _stored_at} -> span_data end) + |> Enum.sort_by(& &1.start_time) + end + + @spec update_span(SpanRecord.t(), keyword()) :: :ok + def update_span(%{parent_span_id: parent_span_id} = span_data, opts \\ []) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + stored_at = System.system_time(:second) + + key = + if parent_span_id == nil do + {:root_span, span_data.span_id} + else + {:child_span, parent_span_id, span_data.span_id} + end + + :ets.update_element(table_name, key, [{2, span_data}, {3, stored_at}]) + + :ok + end + + @spec remove_root_span(String.t(), keyword()) :: :ok + def remove_root_span(span_id, opts \\ []) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + key = {:root_span, span_id} + + :ets.select_delete(table_name, [{{key, :_, :_}, [], [true]}]) + remove_child_spans(span_id, table_name: table_name) + + :ok + end + + @spec remove_child_spans(String.t(), keyword()) :: :ok + def remove_child_spans(parent_span_id, opts) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + + :ets.select_delete(table_name, [ + {{{:child_span, parent_span_id, :_}, :_, :_}, [], [true]} + ]) + + :ok + end + + defp schedule_cleanup(interval) do + Process.send_after(self(), :cleanup_stale_spans, interval) + end + + defp cleanup_stale_spans(table_name) do + now = System.system_time(:second) + cutoff_time = now - @span_ttl + + root_match_spec = [ + {{{:root_span, :"$1"}, :_, :"$2"}, [{:<, :"$2", cutoff_time}], [:"$1"]} + ] + + expired_root_spans = :ets.select(table_name, root_match_spec) + + Enum.each(expired_root_spans, fn span_id -> + remove_root_span(span_id, table_name: table_name) + end) + + child_match_spec = [ + {{{:child_span, :_, :_}, :_, :"$1"}, [{:<, :"$1", cutoff_time}], [true]} + ] + + :ets.select_delete(table_name, child_match_spec) + end + + defp default_table_name do + Module.concat(__MODULE__, ETSTable) + end +end diff --git a/mix.exs b/mix.exs index 0b690b68..3e588b28 100644 --- a/mix.exs +++ b/mix.exs @@ -75,7 +75,7 @@ defmodule Sentry.Mixfile do def application do [ mod: {Sentry.Application, []}, - extra_applications: [:logger], + extra_applications: extra_applications(Mix.env()), registered: [ Sentry.Dedupe, Sentry.Transport.SenderRegistry, @@ -84,6 +84,9 @@ defmodule Sentry.Mixfile do ] end + defp extra_applications(:test), do: [:logger, :opentelemetry] + defp extra_applications(_other), do: [:logger] + defp elixirc_paths(:test), do: ["test/support"] ++ elixirc_paths(:dev) defp elixirc_paths(_other), do: ["lib"] @@ -114,7 +117,11 @@ defmodule Sentry.Mixfile do # Required by Phoenix.LiveView's testing {:floki, ">= 0.30.0", only: :test}, {:oban, "~> 2.17 and >= 2.17.6", only: [:test]}, - {:quantum, "~> 3.0", only: [:test]} + {:quantum, "~> 3.0", only: [:test]}, + {:opentelemetry, "~> 1.5", optional: true}, + {:opentelemetry_api, "~> 1.4", optional: true}, + {:opentelemetry_exporter, "~> 1.0", optional: true}, + {:opentelemetry_semantic_conventions, "~> 1.27", optional: true} ] end diff --git a/mix.lock b/mix.lock index 6d1645cb..faafc367 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,15 @@ %{ + "acceptor_pool": {:hex, :acceptor_pool, "1.0.0", "43c20d2acae35f0c2bcd64f9d2bde267e459f0f3fd23dab26485bf518c281b21", [:rebar3], [], "hexpm", "0cbcd83fdc8b9ad2eee2067ef8b91a14858a5883cb7cd800e6fcd5803e158788"}, "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, "crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"}, + "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, @@ -20,7 +23,10 @@ "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, + "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "igniter": {:hex, :igniter, "0.6.3", "8bfaf5955ce83301da0f0a53455f73a0bc4dc5aacd6c311363089850a5dc2dd7", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "95d34d8280dea992e05dcbf9865414d69e421a76d743661eaf1d1337ea54fa80"}, @@ -37,6 +43,10 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"}, + "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.8.0", "5d546123230771ef4174e37bedfd77e3374913304cd6ea3ca82a2add49cd5d56", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "a1f9f271f8d3b02b81462a6bfef7075fd8457fdb06adff5d2537df5e2264d9af"}, + "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, @@ -58,6 +68,7 @@ "telemetry_registry": {:hex, :telemetry_registry, "0.3.2", "701576890320be6428189bff963e865e8f23e0ff3615eade8f78662be0fc003c", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7ed191eb1d115a3034af8e1e35e4e63d5348851d556646d46ca3d1b4e16bab9"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.28.0", "c39bf21f67c2d124ae905454fad00f27e625917e8ab1009146e916e1df6ab275", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3ab058c3f9457fffca916729587415f0ddc822048a0e5b5e2694918556d92df1"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, diff --git a/test/event_test.exs b/test/event_test.exs index a0fdec9c..79b3b9ae 100644 --- a/test/event_test.exs +++ b/test/event_test.exs @@ -475,7 +475,7 @@ defmodule Sentry.EventTest do exception = RuntimeError.exception("error") event = Sentry.Event.transform_exception(exception, []) - assert ["asn1", "bandit", "bypass" | _rest] = + assert ["acceptor_pool", "asn1", "bandit", "bypass" | _rest] = event.modules |> Map.keys() |> Enum.sort() diff --git a/test/sentry/config_test.exs b/test/sentry/config_test.exs index 03750c34..9bbaad1f 100644 --- a/test/sentry/config_test.exs +++ b/test/sentry/config_test.exs @@ -153,6 +153,15 @@ defmodule Sentry.ConfigTest do end end + test ":traces_sample_rate" do + assert Config.validate!(traces_sample_rate: 1.0)[:traces_sample_rate] == 1.0 + assert Config.validate!([])[:traces_sample_rate] == 0.0 + + assert_raise ArgumentError, ~r/invalid value for :traces_sample_rate option/, fn -> + Config.validate!(traces_sample_rate: 2.0) + end + end + test ":json_library" do assert Config.validate!(json_library: Jason)[:json_library] == Jason diff --git a/test/sentry/opentelemetry/sampler_test.exs b/test/sentry/opentelemetry/sampler_test.exs new file mode 100644 index 00000000..e2196c0a --- /dev/null +++ b/test/sentry/opentelemetry/sampler_test.exs @@ -0,0 +1,19 @@ +defmodule Sentry.Opentelemetry.SamplerTest do + use Sentry.Case, async: true + + alias Sentry.OpenTelemetry.Sampler + + test "drops spans with the given name" do + assert {:drop, [], []} = + Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Stager process", nil, nil, + drop: ["Elixir.Oban.Stager process"] + ) + end + + test "records and samples spans with the given name" do + assert {:record_and_sample, [], []} = + Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Worker process", nil, nil, + drop: [] + ) + end +end diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs new file mode 100644 index 00000000..a454a654 --- /dev/null +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -0,0 +1,123 @@ +defmodule Sentry.Opentelemetry.SpanProcessorTest do + use Sentry.Case, async: false + + import Sentry.TestHelpers + + alias Sentry.OpenTelemetry.SpanStorage + + defmodule TestEndpoint do + require OpenTelemetry.Tracer, as: Tracer + + def instrumented_function do + Tracer.with_span "instrumented_function" do + Process.sleep(100) + + child_instrumented_function("one") + child_instrumented_function("two") + end + end + + def child_instrumented_function(name) do + Tracer.with_span "child_instrumented_function_#{name}" do + Process.sleep(140) + end + end + end + + @tag span_storage: true + test "sends captured root spans as transactions" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + TestEndpoint.child_instrumented_function("one") + + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + assert transaction.event_id + assert transaction.environment == "test" + assert transaction.transaction_info == %{source: :custom} + assert_valid_iso8601(transaction.timestamp) + assert_valid_iso8601(transaction.start_timestamp) + assert transaction.timestamp > transaction.start_timestamp + assert_valid_trace_id(transaction.contexts.trace.trace_id) + assert length(transaction.spans) == 0 + end + + @tag span_storage: true + test "sends captured spans as transactions with child spans" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + TestEndpoint.instrumented_function() + + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + assert_valid_iso8601(transaction.timestamp) + assert_valid_iso8601(transaction.start_timestamp) + assert transaction.timestamp > transaction.start_timestamp + assert length(transaction.spans) == 2 + + [child_span_one, child_span_two] = transaction.spans + assert child_span_one.op == "child_instrumented_function_one" + assert child_span_two.op == "child_instrumented_function_two" + assert child_span_one.parent_span_id == transaction.contexts.trace.span_id + assert child_span_two.parent_span_id == transaction.contexts.trace.span_id + + assert_valid_iso8601(child_span_one.timestamp) + assert_valid_iso8601(child_span_one.start_timestamp) + assert_valid_iso8601(child_span_two.timestamp) + assert_valid_iso8601(child_span_two.start_timestamp) + + assert child_span_one.timestamp > child_span_one.start_timestamp + assert child_span_two.timestamp > child_span_two.start_timestamp + assert transaction.timestamp >= child_span_one.timestamp + assert transaction.timestamp >= child_span_two.timestamp + assert transaction.start_timestamp <= child_span_one.start_timestamp + assert transaction.start_timestamp <= child_span_two.start_timestamp + + assert_valid_trace_id(transaction.contexts.trace.trace_id) + assert_valid_trace_id(child_span_one.trace_id) + assert_valid_trace_id(child_span_two.trace_id) + end + + @tag span_storage: true + test "removes span records from storage after sending a transaction", %{table_name: table_name} do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + TestEndpoint.instrumented_function() + + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + assert SpanStorage.get_root_span(transaction.contexts.trace.span_id, table_name: table_name) == + nil + + assert [] == + SpanStorage.get_child_spans(transaction.contexts.trace.span_id, + table_name: table_name + ) + end + + defp assert_valid_iso8601(timestamp) do + case DateTime.from_iso8601(timestamp) do + {:ok, datetime, _offset} -> + assert datetime.year >= 2023, "Expected year to be 2023 or later, got: #{datetime.year}" + assert is_binary(timestamp), "Expected timestamp to be a string" + assert String.ends_with?(timestamp, "Z"), "Expected timestamp to end with 'Z'" + + {:error, reason} -> + flunk("Invalid ISO8601 timestamp: #{timestamp}, reason: #{inspect(reason)}") + end + end + + defp assert_valid_trace_id(trace_id) do + assert is_binary(trace_id), "Expected trace_id to be a string" + assert byte_size(trace_id) == 32, "Expected trace_id to be 32 characters long #{trace_id}" + + assert String.match?(trace_id, ~r/^[a-f0-9]{32}$/), + "Expected trace_id to be a lowercase hex string" + end +end diff --git a/test/sentry/opentelemetry/span_storage_test.exs b/test/sentry/opentelemetry/span_storage_test.exs new file mode 100644 index 00000000..5a5548b2 --- /dev/null +++ b/test/sentry/opentelemetry/span_storage_test.exs @@ -0,0 +1,489 @@ +defmodule Sentry.OpenTelemetry.SpanStorageTest do + use Sentry.Case, async: true + + alias Sentry.OpenTelemetry.{SpanStorage, SpanRecord} + + describe "root spans" do + @tag span_storage: true + test "stores and retrieves a root span", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "abc123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + SpanStorage.store_span(root_span, table_name: table_name) + + assert ^root_span = SpanStorage.get_root_span("abc123", table_name: table_name) + end + + @tag span_storage: true + test "updates an existing root span", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "abc123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + updated_root_span = %SpanRecord{ + span_id: "abc123", + parent_span_id: nil, + trace_id: "trace123", + name: "updated_root_span" + } + + SpanStorage.store_span(root_span, table_name: table_name) + SpanStorage.update_span(updated_root_span, table_name: table_name) + + assert ^updated_root_span = SpanStorage.get_root_span("abc123", table_name: table_name) + end + + @tag span_storage: true + test "removes a root span", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "abc123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + SpanStorage.store_span(root_span, table_name: table_name) + assert root_span == SpanStorage.get_root_span("abc123", table_name: table_name) + + SpanStorage.remove_root_span("abc123", table_name: table_name) + assert SpanStorage.get_root_span("abc123", table_name: table_name) == nil + end + + @tag span_storage: true + test "removes root span and all its children", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "root123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + child_span1 = %SpanRecord{ + span_id: "child1", + parent_span_id: "root123", + trace_id: "trace123", + name: "child_span_1" + } + + child_span2 = %SpanRecord{ + span_id: "child2", + parent_span_id: "root123", + trace_id: "trace123", + name: "child_span_2" + } + + SpanStorage.store_span(root_span, table_name: table_name) + SpanStorage.store_span(child_span1, table_name: table_name) + SpanStorage.store_span(child_span2, table_name: table_name) + + assert root_span == SpanStorage.get_root_span("root123", table_name: table_name) + assert length(SpanStorage.get_child_spans("root123", table_name: table_name)) == 2 + + SpanStorage.remove_root_span("root123", table_name: table_name) + + assert SpanStorage.get_root_span("root123", table_name: table_name) == nil + assert SpanStorage.get_child_spans("root123", table_name: table_name) == [] + end + end + + describe "child spans" do + @tag span_storage: true + test "stores and retrieves child spans", %{table_name: table_name} do + child_span1 = %SpanRecord{ + span_id: "child1", + parent_span_id: "parent123", + trace_id: "trace123", + name: "child_span_1" + } + + child_span2 = %SpanRecord{ + span_id: "child2", + parent_span_id: "parent123", + trace_id: "trace123", + name: "child_span_2" + } + + SpanStorage.store_span(child_span1, table_name: table_name) + SpanStorage.store_span(child_span2, table_name: table_name) + + children = SpanStorage.get_child_spans("parent123", table_name: table_name) + assert length(children) == 2 + assert child_span1 in children + assert child_span2 in children + end + + @tag span_storage: true + test "updates an existing child span", %{table_name: table_name} do + child_span = %SpanRecord{ + span_id: "child1", + parent_span_id: "parent123", + trace_id: "trace123", + name: "child_span" + } + + updated_child_span = %SpanRecord{ + span_id: "child1", + parent_span_id: "parent123", + trace_id: "trace123", + name: "updated_child_span" + } + + SpanStorage.store_span(child_span, table_name: table_name) + SpanStorage.update_span(updated_child_span, table_name: table_name) + + children = SpanStorage.get_child_spans("parent123", table_name: table_name) + assert [^updated_child_span] = children + end + + @tag span_storage: true + test "removes child spans", %{table_name: table_name} do + child_span1 = %SpanRecord{ + span_id: "child1", + parent_span_id: "parent123", + trace_id: "trace123", + name: "child_span_1" + } + + child_span2 = %SpanRecord{ + span_id: "child2", + parent_span_id: "parent123", + trace_id: "trace123", + name: "child_span_2" + } + + SpanStorage.store_span(child_span1, table_name: table_name) + SpanStorage.store_span(child_span2, table_name: table_name) + assert length(SpanStorage.get_child_spans("parent123", table_name: table_name)) == 2 + + SpanStorage.remove_child_spans("parent123", table_name: table_name) + assert [] == SpanStorage.get_child_spans("parent123", table_name: table_name) + end + end + + @tag span_storage: true + test "handles complete span hierarchy", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "root123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + child_span1 = %SpanRecord{ + span_id: "child1", + parent_span_id: "root123", + trace_id: "trace123", + name: "child_span_1" + } + + child_span2 = %SpanRecord{ + span_id: "child2", + parent_span_id: "root123", + trace_id: "trace123", + name: "child_span_2" + } + + SpanStorage.store_span(root_span, table_name: table_name) + SpanStorage.store_span(child_span1, table_name: table_name) + SpanStorage.store_span(child_span2, table_name: table_name) + + assert ^root_span = SpanStorage.get_root_span("root123", table_name: table_name) + + children = SpanStorage.get_child_spans("root123", table_name: table_name) + assert length(children) == 2 + assert child_span1 in children + assert child_span2 in children + + SpanStorage.remove_root_span("root123", table_name: table_name) + SpanStorage.remove_child_spans("root123", table_name: table_name) + + assert SpanStorage.get_root_span("root123", table_name: table_name) == nil + assert SpanStorage.get_child_spans("root123", table_name: table_name) == [] + end + + describe "stale span cleanup" do + @tag span_storage: [cleanup_interval: 100] + test "cleans up stale spans", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "stale_root", + parent_span_id: nil, + trace_id: "trace123", + name: "stale_root_span" + } + + child_span = %SpanRecord{ + span_id: "stale_child", + parent_span_id: "stale_root", + trace_id: "trace123", + name: "stale_child_span" + } + + old_time = DateTime.utc_now() |> DateTime.add(-1860, :second) |> DateTime.to_unix() + + :ets.insert(table_name, {{:root_span, "stale_root"}, root_span, old_time}) + :ets.insert(table_name, {{:child_span, "stale_root", "stale_child"}, child_span, old_time}) + + fresh_root_span = %SpanRecord{ + span_id: "fresh_root", + parent_span_id: nil, + trace_id: "trace123", + name: "fresh_root_span" + } + + SpanStorage.store_span(fresh_root_span, table_name: table_name) + + Process.sleep(200) + + assert SpanStorage.get_root_span("stale_root", table_name: table_name) == nil + assert SpanStorage.get_child_spans("stale_root", table_name: table_name) == [] + + assert SpanStorage.get_root_span("fresh_root", table_name: table_name) + end + + @tag span_storage: [cleanup_interval: 100] + test "cleans up orphaned child spans", %{table_name: table_name} do + child_span = %SpanRecord{ + span_id: "stale_child", + parent_span_id: "non_existent_parent", + trace_id: "trace123", + name: "stale_child_span" + } + + # 31 minutes = 1860 seconds + old_time = DateTime.utc_now() |> DateTime.add(-1860, :second) |> DateTime.to_unix() + :ets.insert(table_name, {"non_existent_parent", {child_span, old_time}}) + + Process.sleep(200) + + assert [] == SpanStorage.get_child_spans("non_existent_parent", table_name: table_name) + end + + @tag span_storage: [cleanup_interval: 100] + test "cleans up expired root spans with all their children regardless of child timestamps", %{ + table_name: table_name + } do + root_span = %SpanRecord{ + span_id: "root123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + old_child = %SpanRecord{ + span_id: "old_child", + parent_span_id: "root123", + trace_id: "trace123", + name: "old_child_span" + } + + fresh_child = %SpanRecord{ + span_id: "fresh_child", + parent_span_id: "root123", + trace_id: "trace123", + name: "fresh_child_span" + } + + old_time = DateTime.utc_now() |> DateTime.add(-1860, :second) |> DateTime.to_unix() + :ets.insert(table_name, {{:root_span, "root123"}, root_span, old_time}) + + :ets.insert(table_name, {"root123", {old_child, old_time}}) + SpanStorage.store_span(fresh_child, table_name: table_name) + + Process.sleep(200) + + assert SpanStorage.get_root_span("root123", table_name: table_name) == nil + assert SpanStorage.get_child_spans("root123", table_name: table_name) == [] + end + + @tag span_storage: [cleanup_interval: 100] + test "handles mixed expiration times in child spans", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "root123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + old_child1 = %SpanRecord{ + span_id: "old_child1", + parent_span_id: "root123", + trace_id: "trace123", + name: "old_child_span_1" + } + + old_child2 = %SpanRecord{ + span_id: "old_child2", + parent_span_id: "root123", + trace_id: "trace123", + name: "old_child_span_2" + } + + fresh_child = %SpanRecord{ + span_id: "fresh_child", + parent_span_id: "root123", + trace_id: "trace123", + name: "fresh_child_span" + } + + SpanStorage.store_span(root_span, table_name: table_name) + + old_time = DateTime.utc_now() |> DateTime.add(-1860, :second) |> DateTime.to_unix() + :ets.insert(table_name, {"root123", {old_child1, old_time}}) + :ets.insert(table_name, {"root123", {old_child2, old_time}}) + + SpanStorage.store_span(fresh_child, table_name: table_name) + + Process.sleep(200) + + assert root_span == SpanStorage.get_root_span("root123", table_name: table_name) + children = SpanStorage.get_child_spans("root123", table_name: table_name) + assert length(children) == 1 + assert fresh_child in children + refute old_child1 in children + refute old_child2 in children + end + end + + describe "concurrent operations" do + @tag span_storage: true + test "handles concurrent span updates safely", %{table_name: table_name} do + root_span = %SpanRecord{ + span_id: "root123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + SpanStorage.store_span(root_span, table_name: table_name) + + tasks = + for i <- 1..10000 do + Task.async(fn -> + updated_span = %{root_span | name: "updated_name_#{i}"} + SpanStorage.update_span(updated_span, table_name: table_name) + end) + end + + Task.await_many(tasks) + + result = SpanStorage.get_root_span("root123", table_name: table_name) + assert result.span_id == "root123" + assert result.name =~ ~r/^updated_name_\d+$/ + end + + @tag span_storage: true + test "handles concurrent child span operations", %{table_name: table_name} do + parent_id = "parent123" + + tasks = + for i <- 1..10000 do + Task.async(fn -> + child_span = %SpanRecord{ + span_id: "child_#{i}", + parent_span_id: parent_id, + trace_id: "trace123", + name: "child_span_#{i}" + } + + SpanStorage.store_span(child_span, table_name: table_name) + end) + end + + Task.await_many(tasks) + + children = SpanStorage.get_child_spans(parent_id, table_name: table_name) + assert length(children) == 10000 + assert Enum.all?(children, &(&1.parent_span_id == parent_id)) + end + end + + describe "span timestamps" do + @tag span_storage: true + test "maintains correct timestamp ordering", %{table_name: table_name} do + now = System.system_time(:second) + + spans = + for i <- 1..5 do + %SpanRecord{ + span_id: "span_#{i}", + parent_span_id: "parent123", + trace_id: "trace123", + name: "span_#{i}", + start_time: now + i, + end_time: now + i + 10 + } + end + + Enum.reverse(spans) + |> Enum.each(&SpanStorage.store_span(&1, table_name: table_name)) + + retrieved_spans = SpanStorage.get_child_spans("parent123", table_name: table_name) + assert length(retrieved_spans) == 5 + + assert retrieved_spans + |> Enum.map(& &1.start_time) + |> Enum.chunk_every(2, 1, :discard) + |> Enum.all?(fn [a, b] -> a <= b end) + end + end + + describe "cleanup" do + @tag span_storage: [cleanup_interval: 100] + test "cleanup respects span TTL precisely", %{table_name: table_name} do + now = System.system_time(:second) + ttl = 1800 + + spans = [ + {now - ttl - 1, "too_old"}, + {now - ttl + 1, "just_fresh"}, + {now - div(ttl, 2), "middle_aged"}, + {now, "fresh"} + ] + + Enum.each(spans, fn {timestamp, name} -> + span = %SpanRecord{ + span_id: name, + parent_span_id: nil, + trace_id: "trace123", + name: name + } + + :ets.insert(table_name, {{:root_span, name}, span, timestamp}) + end) + + Process.sleep(200) + + assert SpanStorage.get_root_span("too_old", table_name: table_name) == nil + assert not is_nil(SpanStorage.get_root_span("just_fresh", table_name: table_name)) + assert not is_nil(SpanStorage.get_root_span("middle_aged", table_name: table_name)) + assert not is_nil(SpanStorage.get_root_span("fresh", table_name: table_name)) + end + + @tag span_storage: [cleanup_interval: 100] + test "cleanup handles large number of expired spans efficiently", %{table_name: table_name} do + old_time = System.system_time(:second) - :timer.minutes(31) + + for i <- 1..10000 do + root_span = %SpanRecord{ + span_id: "span_#{i}", + parent_span_id: nil, + trace_id: "trace123", + name: "span_#{i}" + } + + :ets.insert(table_name, {{:root_span, "span_#{i}"}, root_span, old_time}) + end + + Process.sleep(200) + + assert :ets.info(table_name, :size) == 0 + end + end +end diff --git a/test/support/case.ex b/test/support/case.ex index c4ae3ed3..85103114 100644 --- a/test/support/case.ex +++ b/test/support/case.ex @@ -7,11 +7,28 @@ defmodule Sentry.Case do import Sentry.TestHelpers - setup do + setup context do config_before = all_config() on_exit(fn -> assert config_before == all_config() end) + + case context[:span_storage] do + nil -> :ok + true -> setup_span_storage([]) + opts when is_list(opts) -> setup_span_storage(opts) + end + end + + defp setup_span_storage(opts) do + uid = System.unique_integer([:positive]) + server_name = :"test_span_storage_#{uid}" + table_name = :"test_span_storage_table_#{uid}" + + opts = [name: server_name, table_name: table_name] ++ opts + start_supervised!({Sentry.OpenTelemetry.SpanStorage, opts}) + + {:ok, server_name: server_name, table_name: table_name} end end diff --git a/test_integrations/phoenix_app/config/test.exs b/test_integrations/phoenix_app/config/test.exs index 207b9cf2..f845417b 100644 --- a/test_integrations/phoenix_app/config/test.exs +++ b/test_integrations/phoenix_app/config/test.exs @@ -30,3 +30,5 @@ config :sentry, root_source_code_paths: [File.cwd!()], test_mode: true, send_result: :sync + +config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} diff --git a/test_integrations/phoenix_app/mix.exs b/test_integrations/phoenix_app/mix.exs index 2055e414..ad10a3da 100644 --- a/test_integrations/phoenix_app/mix.exs +++ b/test_integrations/phoenix_app/mix.exs @@ -55,7 +55,12 @@ defmodule PhoenixApp.MixProject do {:bypass, "~> 2.1", only: :test}, {:hackney, "~> 1.18", only: :test}, - {:sentry, path: "../.."} + {:sentry, path: "../.."}, + + {:opentelemetry, "~> 1.5"}, + {:opentelemetry_api, "~> 1.4"}, + {:opentelemetry_exporter, "~> 1.0"}, + {:opentelemetry_semantic_conventions, "~> 1.27"} ] end diff --git a/test_integrations/phoenix_app/mix.lock b/test_integrations/phoenix_app/mix.lock index a14316ee..11d417cf 100644 --- a/test_integrations/phoenix_app/mix.lock +++ b/test_integrations/phoenix_app/mix.lock @@ -1,11 +1,14 @@ %{ + "acceptor_pool": {:hex, :acceptor_pool, "1.0.0", "43c20d2acae35f0c2bcd64f9d2bde267e459f0f3fd23dab26485bf518c281b21", [:rebar3], [], "hexpm", "0cbcd83fdc8b9ad2eee2067ef8b91a14858a5883cb7cd800e6fcd5803e158788"}, "bandit": {:hex, :bandit, "1.6.1", "9e01b93d72ddc21d8c576a704949e86ee6cde7d11270a1d3073787876527a48f", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5a904bf010ea24b67979835e0507688e31ac873d4ffc8ed0e5413e8d77455031"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, + "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, @@ -15,7 +18,10 @@ "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, + "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, + "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -26,6 +32,10 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.8.0", "5d546123230771ef4174e37bedfd77e3374913304cd6ea3ca82a2add49cd5d56", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "a1f9f271f8d3b02b81462a6bfef7075fd8457fdb06adff5d2537df5e2264d9af"}, + "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, @@ -46,6 +56,7 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, "thousand_island": {:hex, :thousand_island, "1.3.7", "1da7598c0f4f5f50562c097a3f8af308ded48cd35139f0e6f17d9443e4d0c9c5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0139335079953de41d381a6134d8b618d53d084f558c734f2662d1a72818dd12"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.27.0", "2c1c7fc922a329b9eb45ddf39113c998bbdeb28a534219cd884431e2aee1811e", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "51a5ad3dbd72d4694848965f3b5076e8b55d70eb8d5057fcddd536029ab8a23c"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, diff --git a/test_integrations/umbrella/mix.lock b/test_integrations/umbrella/mix.lock index ff9454dd..4aea5a19 100644 --- a/test_integrations/umbrella/mix.lock +++ b/test_integrations/umbrella/mix.lock @@ -1,7 +1,8 @@ %{ - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, - "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, + "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, From 58ce1118205ace706896ff8ce66d51d18af8c537 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 10 Jun 2025 15:26:37 +0200 Subject: [PATCH 2/5] Better span sampler (#903) * Better span sampler This updates OpenTelemetry.Sampler so that it uses `traces_sample_rate` for sampling spans before they get sent to the span processor. This way we're not processing spans when they are dropped during sampling, which was previously the case as the Client was responsible for sampling just before attemping to send a transaction. * Enhance sampling logic to record discarded transactions * Clarify trace-level sampling decision-making Previously it incorrectly referred to "parent" where in reality we're dealing with spans from the same trace and respect sampling decisions that were already made for a given trace. This is not the same thing as parent/child spans that we're handling in the SpanProcessor as this type of relationship is established post-sampling. * Remove unnecessary sleep calls in sampler tests * Fix flaky test * Clean up make_sampling_decision * Simplify client reports for dropped transactions * Update traces_sample_rate default value to be nil * Account for `nil` sample_rate when sampling * Use put_test_config * Update traces_sample_rate spec to allow nil value --- lib/sentry/client.ex | 9 +- lib/sentry/client_report/sender.ex | 11 +- lib/sentry/config.ex | 19 +- lib/sentry/opentelemetry/sampler.ex | 107 ++++++- lib/sentry/opentelemetry/span_processor.ex | 3 + test/sentry/config_test.exs | 6 +- test/sentry/opentelemetry/sampler_test.exs | 301 +++++++++++++++++- .../opentelemetry/span_processor_test.exs | 191 +++++++++++ test/sentry_test.exs | 4 - 9 files changed, 611 insertions(+), 40 deletions(-) diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index f2c0ebb2..dc924bd8 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -110,14 +110,12 @@ defmodule Sentry.Client do @spec send_transaction(Transaction.t(), keyword()) :: {:ok, transaction_id :: String.t()} | {:error, ClientError.t()} - | :unsampled | :excluded def send_transaction(%Transaction{} = transaction, opts \\ []) do opts = NimbleOptions.validate!(opts, Options.send_transaction_schema()) result_type = Keyword.get_lazy(opts, :result, &Config.send_result/0) client = Keyword.get_lazy(opts, :client, &Config.client/0) - sample_rate = Keyword.get_lazy(opts, :sample_rate, &Config.traces_sample_rate/0) before_send = Keyword.get_lazy(opts, :before_send, &Config.before_send/0) after_send_event = Keyword.get_lazy(opts, :after_send_event, &Config.after_send_event/0) @@ -126,16 +124,11 @@ defmodule Sentry.Client do Application.get_env(:sentry, :request_retries, Transport.default_retries()) end) - with :ok <- sample_event(sample_rate), - {:ok, %Transaction{} = transaction} <- maybe_call_before_send(transaction, before_send) do + with {:ok, %Transaction{} = transaction} <- maybe_call_before_send(transaction, before_send) do send_result = encode_and_send(transaction, result_type, client, request_retries) _ignored = maybe_call_after_send(transaction, send_result, after_send_event) send_result else - :unsampled -> - ClientReport.Sender.record_discarded_events(:sample_rate, [transaction]) - :unsampled - :excluded -> :excluded end diff --git a/lib/sentry/client_report/sender.ex b/lib/sentry/client_report/sender.ex index 99a18e18..d1c91d64 100644 --- a/lib/sentry/client_report/sender.ex +++ b/lib/sentry/client_report/sender.ex @@ -17,14 +17,21 @@ defmodule Sentry.ClientReport.Sender do GenServer.start_link(__MODULE__, nil, name: Keyword.get(opts, :name, __MODULE__)) end + def record_discarded_events(reason, info, genserver \\ __MODULE__) + + @spec record_discarded_events(atom(), String.t(), GenServer.server()) :: :ok + def record_discarded_events(reason, data_category, genserver) + when is_binary(data_category) do + GenServer.cast(genserver, {:record_discarded_events, reason, data_category}) + end + @spec record_discarded_events(atom(), [item], GenServer.server()) :: :ok when item: Sentry.Attachment.t() | Sentry.CheckIn.t() | ClientReport.t() | Sentry.Event.t() - | Sentry.Transaction.t() - def record_discarded_events(reason, event_items, genserver \\ __MODULE__) + def record_discarded_events(reason, event_items, genserver) when is_list(event_items) do # We silently ignore events whose reasons aren't valid because we have to add it to the allowlist in Snuba # https://develop.sentry.dev/sdk/client-reports/ diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index f07777dd..7d21873d 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -145,12 +145,13 @@ defmodule Sentry.Config do ], traces_sample_rate: [ type: {:custom, __MODULE__, :__validate_traces_sample_rate__, []}, - default: 0.0, + default: nil, doc: """ The sample rate for transaction events. A value between `0.0` and `1.0` (inclusive). A value of `0.0` means no transactions will be sampled, while `1.0` means all transactions - will be sampled. This value is also used to determine if tracing is enabled: if it's - greater than `0`, tracing is enabled. + will be sampled. + + This value is also used to determine if tracing is enabled: if it's not `nil`, tracing is enabled. Tracing requires OpenTelemetry packages to work. See [the OpenTelemetry setup documentation](https://opentelemetry.io/docs/languages/erlang/getting-started/) @@ -621,7 +622,7 @@ defmodule Sentry.Config do @spec sample_rate() :: float() def sample_rate, do: fetch!(:sample_rate) - @spec traces_sample_rate() :: float() + @spec traces_sample_rate() :: nil | float() def traces_sample_rate, do: fetch!(:traces_sample_rate) @spec hackney_opts() :: keyword() @@ -662,7 +663,7 @@ defmodule Sentry.Config do def integrations, do: fetch!(:integrations) @spec tracing?() :: boolean() - def tracing?, do: fetch!(:traces_sample_rate) > 0.0 + def tracing?, do: not is_nil(fetch!(:traces_sample_rate)) @spec put_config(atom(), term()) :: :ok def put_config(key, value) when is_atom(key) do @@ -763,12 +764,12 @@ defmodule Sentry.Config do end end - def __validate_traces_sample_rate__(float) do - if is_float(float) and float >= 0.0 and float <= 1.0 do - {:ok, float} + def __validate_traces_sample_rate__(value) do + if is_nil(value) or (is_float(value) and value >= 0.0 and value <= 1.0) do + {:ok, value} else {:error, - "expected :traces_sample_rate to be a float between 0.0 and 1.0 (included), got: #{inspect(float)}"} + "expected :traces_sample_rate to be nil or a value between 0.0 and 1.0 (included), got: #{inspect(value)}"} end end diff --git a/lib/sentry/opentelemetry/sampler.ex b/lib/sentry/opentelemetry/sampler.ex index 18fa5511..2c7d6087 100644 --- a/lib/sentry/opentelemetry/sampler.ex +++ b/lib/sentry/opentelemetry/sampler.ex @@ -2,18 +2,28 @@ if Code.ensure_loaded?(:otel_sampler) do defmodule Sentry.OpenTelemetry.Sampler do @moduledoc false + alias OpenTelemetry.{Span, Tracer} + alias Sentry.ClientReport + @behaviour :otel_sampler + @sentry_sample_rate_key "sentry-sample_rate" + @sentry_sample_rand_key "sentry-sample_rand" + @sentry_sampled_key "sentry-sampled" + + @impl true def setup(config) do config end + @impl true def description(_) do "SentrySampler" end + @impl true def should_sample( - _ctx, + ctx, _trace_id, _links, span_name, @@ -21,11 +31,98 @@ if Code.ensure_loaded?(:otel_sampler) do _attributes, config ) do - if span_name in config[:drop] do - {:drop, [], []} - else - {:record_and_sample, [], []} + result = + if span_name in config[:drop] do + {:drop, [], []} + else + sample_rate = Sentry.Config.traces_sample_rate() + + case get_trace_sampling_decision(ctx) do + {:inherit, trace_sampled, tracestate} -> + decision = if trace_sampled, do: :record_and_sample, else: :drop + + {decision, [], tracestate} + + :no_trace -> + make_sampling_decision(sample_rate) + end + end + + case result do + {:drop, _, _} -> + record_discarded_transaction() + result + + _ -> + result end end + + defp get_trace_sampling_decision(ctx) do + case Tracer.current_span_ctx(ctx) do + :undefined -> + :no_trace + + span_ctx -> + tracestate = Span.tracestate(span_ctx) + trace_sampled = get_tracestate_value(tracestate, @sentry_sampled_key) + + case trace_sampled do + "true" -> + {:inherit, true, tracestate} + + "false" -> + {:inherit, false, tracestate} + + nil -> + :no_trace + end + end + end + + defp make_sampling_decision(sample_rate) do + cond do + is_nil(sample_rate) -> + {:drop, [], []} + + sample_rate == 0.0 -> + tracestate = build_tracestate(sample_rate, 1.0, false) + {:drop, [], tracestate} + + sample_rate == 1.0 -> + tracestate = build_tracestate(sample_rate, 0.0, true) + {:record_and_sample, [], tracestate} + + true -> + random_value = :rand.uniform() + sampled = random_value < sample_rate + tracestate = build_tracestate(sample_rate, random_value, sampled) + decision = if sampled, do: :record_and_sample, else: :drop + {decision, [], tracestate} + end + end + + defp build_tracestate(sample_rate, random_value, sampled) do + [ + {@sentry_sample_rate_key, Float.to_string(sample_rate)}, + {@sentry_sample_rand_key, Float.to_string(random_value)}, + {@sentry_sampled_key, to_string(sampled)} + ] + end + + defp get_tracestate_value({:tracestate, tracestate}, key) do + get_tracestate_value(tracestate, key) + end + + defp get_tracestate_value(tracestate, key) when is_list(tracestate) do + case List.keyfind(tracestate, key, 0) do + {^key, value} -> value + nil -> nil + end + end + + defp record_discarded_transaction() do + ClientReport.Sender.record_discarded_events(:sample_rate, "transaction") + end end end diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 5ee8fb22..af16ac02 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -39,6 +39,9 @@ if Code.ensure_loaded?(OpenTelemetry) do :ignored -> true + :excluded -> + true + {:error, error} -> Logger.warning("Failed to send transaction to Sentry: #{inspect(error)}") {:error, :invalid_span} diff --git a/test/sentry/config_test.exs b/test/sentry/config_test.exs index 9bbaad1f..0cc7a162 100644 --- a/test/sentry/config_test.exs +++ b/test/sentry/config_test.exs @@ -154,8 +154,12 @@ defmodule Sentry.ConfigTest do end test ":traces_sample_rate" do + assert Config.validate!([])[:traces_sample_rate] == nil + + assert Config.validate!(traces_sample_rate: nil)[:traces_sample_rate] == nil + assert Config.validate!(traces_sample_rate: 0.0)[:traces_sample_rate] == 0.0 + assert Config.validate!(traces_sample_rate: 0.5)[:traces_sample_rate] == 0.5 assert Config.validate!(traces_sample_rate: 1.0)[:traces_sample_rate] == 1.0 - assert Config.validate!([])[:traces_sample_rate] == 0.0 assert_raise ArgumentError, ~r/invalid value for :traces_sample_rate option/, fn -> Config.validate!(traces_sample_rate: 2.0) diff --git a/test/sentry/opentelemetry/sampler_test.exs b/test/sentry/opentelemetry/sampler_test.exs index e2196c0a..9b9f2ee7 100644 --- a/test/sentry/opentelemetry/sampler_test.exs +++ b/test/sentry/opentelemetry/sampler_test.exs @@ -1,19 +1,298 @@ defmodule Sentry.Opentelemetry.SamplerTest do - use Sentry.Case, async: true + use Sentry.Case, async: false alias Sentry.OpenTelemetry.Sampler + alias Sentry.ClientReport - test "drops spans with the given name" do - assert {:drop, [], []} = - Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Stager process", nil, nil, - drop: ["Elixir.Oban.Stager process"] - ) + import Sentry.TestHelpers + + defp create_test_span_context(span_id \\ 123_456_789) do + { + :span_ctx, + 12_345_678_901_234_567_890_123_456_789_012, + span_id, + 1, + [], + true, + false, + true, + nil + } + end + + describe "span name dropping" do + test "drops spans with the given name and records discarded event" do + :sys.replace_state(ClientReport.Sender, fn _ -> %{} end) + + test_ctx = create_test_span_context() + + assert {:drop, [], []} = + Sampler.should_sample(test_ctx, nil, nil, "Elixir.Oban.Stager process", nil, nil, + drop: ["Elixir.Oban.Stager process"] + ) + + state = :sys.get_state(ClientReport.Sender) + assert state == %{{:sample_rate, "transaction"} => 1} + end + + test "records and samples spans not in drop list" do + put_test_config(traces_sample_rate: 1.0) + + test_ctx = create_test_span_context() + + assert {:record_and_sample, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "Elixir.Oban.Worker process", nil, nil, + drop: [] + ) + + assert is_list(tracestate) + assert {"sentry-sample_rate", "1.0"} in tracestate + assert {"sentry-sampled", "true"} in tracestate + end + end + + describe "sampling based on traces_sample_rate" do + test "always drops when sample rate is 0.0 and records discarded event" do + :sys.replace_state(ClientReport.Sender, fn _ -> %{} end) + + put_test_config(traces_sample_rate: 0.0) + + test_ctx = create_test_span_context() + + assert {:drop, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, nil, drop: []) + + assert {"sentry-sample_rate", "0.0"} in tracestate + assert {"sentry-sampled", "false"} in tracestate + + state = :sys.get_state(ClientReport.Sender) + assert state == %{{:sample_rate, "transaction"} => 1} + end + + test "always samples when sample rate is 1.0" do + put_test_config(traces_sample_rate: 1.0) + + test_ctx = create_test_span_context() + + assert {:record_and_sample, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, nil, drop: []) + + assert {"sentry-sample_rate", "1.0"} in tracestate + assert {"sentry-sampled", "true"} in tracestate + end + + test "different trace_ids produce different sampling decisions" do + put_test_config(traces_sample_rate: 0.5) + + trace_ids = Enum.to_list(1..100) + + results = + Enum.map(trace_ids, fn trace_id -> + test_ctx = create_test_span_context() + + {decision, [], _tracestate} = + Sampler.should_sample(test_ctx, trace_id, nil, "test span", nil, nil, drop: []) + + decision == :record_and_sample + end) + + sampled_count = Enum.count(results, & &1) + + assert sampled_count > 30 and sampled_count < 70 + end + + test "records discarded events when randomly dropped by sample rate" do + :sys.replace_state(ClientReport.Sender, fn _ -> %{} end) + + put_test_config(traces_sample_rate: 0.001) + + Enum.each(1..50, fn trace_id -> + test_ctx = create_test_span_context() + Sampler.should_sample(test_ctx, trace_id, nil, "test span", nil, nil, drop: []) + end) + + state = :sys.get_state(ClientReport.Sender) + discarded_count = Map.get(state, {:sample_rate, "transaction"}, 0) + assert discarded_count > 0, "Expected some spans to be dropped and recorded" + end + + test "always drops when sample rate is nil (tracing disabled) and records discarded event" do + :sys.replace_state(ClientReport.Sender, fn _ -> %{} end) + + put_test_config(traces_sample_rate: nil) + + test_ctx = create_test_span_context() + + assert {:drop, [], []} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, nil, drop: []) + + state = :sys.get_state(ClientReport.Sender) + assert state == %{{:sample_rate, "transaction"} => 1} + end end - test "records and samples spans with the given name" do - assert {:record_and_sample, [], []} = - Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Worker process", nil, nil, - drop: [] - ) + describe "trace-level sampling consistency" do + defp create_span_context_with_tracestate(trace_id, tracestate) do + { + :span_ctx, + trace_id, + 123_456_789, + 1, + tracestate, + true, + false, + true, + nil + } + end + + test "all spans in trace inherit sampling decision to drop when trace was not sampled" do + :sys.replace_state(ClientReport.Sender, fn _ -> %{} end) + + trace_id = 12_345_678_901_234_567_890_123_456_789_012 + + trace_tracestate = [ + {"sentry-sample_rate", "1.0"}, + {"sentry-sample_rand", "0.5"}, + {"sentry-sampled", "false"} + ] + + existing_span_ctx = create_span_context_with_tracestate(trace_id, trace_tracestate) + + ctx = :otel_ctx.new() + ctx_with_span = :otel_tracer.set_current_span(ctx, existing_span_ctx) + token = :otel_ctx.attach(ctx_with_span) + + try do + result = + Sampler.should_sample(ctx_with_span, trace_id, nil, "new span in trace", nil, nil, + drop: [] + ) + + assert {:drop, [], returned_tracestate} = result + assert returned_tracestate == trace_tracestate + + state = :sys.get_state(ClientReport.Sender) + assert state == %{{:sample_rate, "transaction"} => 1} + after + :otel_ctx.detach(token) + end + end + + test "all spans in trace inherit sampling decision to sample when trace was sampled" do + trace_id = 12_345_678_901_234_567_890_123_456_789_012 + + # Simulate existing trace context with sample decision + trace_tracestate = [ + {"sentry-sample_rate", "1.0"}, + {"sentry-sample_rand", "0.5"}, + {"sentry-sampled", "true"} + ] + + existing_span_ctx = create_span_context_with_tracestate(trace_id, trace_tracestate) + + ctx = :otel_ctx.new() + ctx_with_span = :otel_tracer.set_current_span(ctx, existing_span_ctx) + token = :otel_ctx.attach(ctx_with_span) + + try do + result = + Sampler.should_sample(ctx_with_span, trace_id, nil, "new span in trace", nil, nil, + drop: [] + ) + + assert {:record_and_sample, [], returned_tracestate} = result + assert returned_tracestate == trace_tracestate + after + :otel_ctx.detach(token) + end + end + + test "makes new sampling decision when no existing trace context" do + put_test_config(traces_sample_rate: 1.0) + + test_ctx = create_test_span_context() + + assert {:record_and_sample, [], _tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "root span", nil, nil, drop: []) + end + + test "makes new sampling decision when tracestate has no sentry sampling info" do + trace_id = 12_345_678_901_234_567_890_123_456_789_012 + + non_sentry_tracestate = [ + {"other-system", "some-value"} + ] + + existing_span_ctx = create_span_context_with_tracestate(trace_id, non_sentry_tracestate) + + ctx = :otel_ctx.new() + ctx_with_span = :otel_tracer.set_current_span(ctx, existing_span_ctx) + token = :otel_ctx.attach(ctx_with_span) + + try do + put_test_config(traces_sample_rate: 1.0) + + result = + Sampler.should_sample(ctx_with_span, trace_id, nil, "span in external trace", nil, nil, + drop: [] + ) + + assert {:record_and_sample, [], new_tracestate} = result + assert {"sentry-sampled", "true"} in new_tracestate + after + :otel_ctx.detach(token) + end + end + + test "trace_id parameter is now irrelevant for inheritance decisions" do + trace_id = 12_345_678_901_234_567_890_123_456_789_012 + different_trace_id = 98_765_432_109_876_543_210_987_654_321_098 + + trace_tracestate = [ + {"sentry-sample_rate", "1.0"}, + {"sentry-sample_rand", "0.5"}, + {"sentry-sampled", "false"} + ] + + existing_span_ctx = create_span_context_with_tracestate(trace_id, trace_tracestate) + + ctx = :otel_ctx.new() + ctx_with_span = :otel_tracer.set_current_span(ctx, existing_span_ctx) + token = :otel_ctx.attach(ctx_with_span) + + try do + result = + Sampler.should_sample(ctx_with_span, different_trace_id, nil, "span", nil, nil, + drop: [] + ) + + assert {:drop, [], returned_tracestate} = result + assert returned_tracestate == trace_tracestate + after + :otel_ctx.detach(token) + end + end + end + + describe "tracestate management" do + test "builds tracestate with correct format" do + put_test_config(traces_sample_rate: 0.75) + + test_ctx = create_test_span_context() + + {_decision, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, nil, drop: []) + + assert List.keyfind(tracestate, "sentry-sample_rate", 0) + assert List.keyfind(tracestate, "sentry-sample_rand", 0) + assert List.keyfind(tracestate, "sentry-sampled", 0) + + {"sentry-sample_rate", rate_str} = List.keyfind(tracestate, "sentry-sample_rate", 0) + assert rate_str == "0.75" + + {"sentry-sampled", sampled_str} = List.keyfind(tracestate, "sentry-sampled", 0) + assert sampled_str in ["true", "false"] + end end end diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index a454a654..331acd08 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -120,4 +120,195 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do assert String.match?(trace_id, ~r/^[a-f0-9]{32}$/), "Expected trace_id to be a lowercase hex string" end + + describe "sampling behavior with root and child spans" do + @tag span_storage: true + test "drops entire trace when root span is not sampled" do + put_test_config(environment_name: "test", traces_sample_rate: 0.0) + + original_sampler = Application.get_env(:opentelemetry, :sampler) + Application.put_env(:opentelemetry, :sampler, {Sentry.OpenTelemetry.Sampler, [drop: []]}) + + Sentry.Test.start_collecting_sentry_reports() + + Enum.each(1..5, fn _ -> + TestEndpoint.instrumented_function() + end) + + assert [] = Sentry.Test.pop_sentry_transactions() + + Application.put_env(:opentelemetry, :sampler, original_sampler) + end + + @tag span_storage: true + test "samples entire trace when root span is sampled" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + TestEndpoint.instrumented_function() + + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + assert length(transaction.spans) == 2 + + [child_span_one, child_span_two] = transaction.spans + assert transaction.contexts.trace.trace_id == child_span_one.trace_id + assert transaction.contexts.trace.trace_id == child_span_two.trace_id + end + + @tag span_storage: true + test "child spans inherit parent sampling decision" do + put_test_config(environment_name: "test", traces_sample_rate: 0.5) + + original_sampler = Application.get_env(:opentelemetry, :sampler) + Application.put_env(:opentelemetry, :sampler, {Sentry.OpenTelemetry.Sampler, [drop: []]}) + + Sentry.Test.start_collecting_sentry_reports() + + Enum.each(1..10, fn _ -> + TestEndpoint.instrumented_function() + end) + + transactions = Sentry.Test.pop_sentry_transactions() + + Enum.each(transactions, fn transaction -> + assert length(transaction.spans) == 2 + + [child_span_one, child_span_two] = transaction.spans + assert transaction.contexts.trace.trace_id == child_span_one.trace_id + assert transaction.contexts.trace.trace_id == child_span_two.trace_id + end) + + Application.put_env(:opentelemetry, :sampler, original_sampler) + end + + @tag span_storage: true + test "nested child spans maintain sampling consistency" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + require OpenTelemetry.Tracer, as: Tracer + + Tracer.with_span "root_span" do + Tracer.with_span "level_1_child" do + Tracer.with_span "level_2_child" do + Process.sleep(10) + end + + Tracer.with_span "level_2_sibling" do + Process.sleep(10) + end + end + + Tracer.with_span "level_1_sibling" do + Process.sleep(10) + end + end + + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + assert length(transaction.spans) == 2 + + trace_id = transaction.contexts.trace.trace_id + + Enum.each(transaction.spans, fn span -> + assert span.trace_id == trace_id + end) + + span_names = Enum.map(transaction.spans, & &1.op) |> Enum.sort() + expected_names = ["level_1_child", "level_1_sibling"] + assert span_names == expected_names + end + + @tag span_storage: true + test "child-only spans without root are handled correctly" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + TestEndpoint.child_instrumented_function("standalone") + + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + assert length(transaction.spans) == 0 + assert transaction.transaction == "child_instrumented_function_standalone" + end + + @tag span_storage: true + test "concurrent traces maintain independent sampling decisions" do + put_test_config(environment_name: "test", traces_sample_rate: 0.5) + + original_sampler = Application.get_env(:opentelemetry, :sampler) + Application.put_env(:opentelemetry, :sampler, {Sentry.OpenTelemetry.Sampler, [drop: []]}) + + Sentry.Test.start_collecting_sentry_reports() + + tasks = + Enum.map(1..20, fn i -> + Task.async(fn -> + require OpenTelemetry.Tracer, as: Tracer + + Tracer.with_span "concurrent_root_#{i}" do + Tracer.with_span "concurrent_child_#{i}" do + Process.sleep(10) + end + end + end) + end) + + Enum.each(tasks, &Task.await/1) + + transactions = Sentry.Test.pop_sentry_transactions() + + Enum.each(transactions, fn transaction -> + assert length(transaction.spans) == 1 + [child_span] = transaction.spans + assert child_span.trace_id == transaction.contexts.trace.trace_id + end) + + assert length(transactions) < 20 + + Application.put_env(:opentelemetry, :sampler, original_sampler) + end + + @tag span_storage: true + test "span processor respects sampler drop configuration" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + original_sampler = Application.get_env(:opentelemetry, :sampler) + + Application.put_env( + :opentelemetry, + :sampler, + {Sentry.OpenTelemetry.Sampler, [drop: ["child_instrumented_function_one"]]} + ) + + Sentry.Test.start_collecting_sentry_reports() + + require OpenTelemetry.Tracer, as: Tracer + + Tracer.with_span "root_span" do + Tracer.with_span "child_instrumented_function_one" do + Process.sleep(10) + end + + Tracer.with_span "allowed_child" do + Process.sleep(10) + end + end + + transactions = Sentry.Test.pop_sentry_transactions() + + Enum.each(transactions, fn transaction -> + trace_id = transaction.contexts.trace.trace_id + + Enum.each(transaction.spans, fn span -> + assert span.trace_id == trace_id + end) + end) + + Application.put_env(:opentelemetry, :sampler, original_sampler) + end + end end diff --git a/test/sentry_test.exs b/test/sentry_test.exs index ca8515e9..1829f0fe 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -300,10 +300,6 @@ defmodule SentryTest do assert {:ok, "340"} = Sentry.send_transaction(transaction, sample_rate: 1.0) end - test "sends client report when sample_rate is 0.0", %{transaction: transaction} do - assert :unsampled = Sentry.send_transaction(transaction, sample_rate: 0.0) - end - test "supports before_send option", %{bypass: bypass, transaction: transaction} do # Exclude transaction assert :excluded = From 499140c3c468b36af8b79786106dd45b00e1f1e7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 17 Jun 2025 09:49:37 +0200 Subject: [PATCH 3/5] Support for traces_sampler option (#910) * Add support for `traces_sampler` config option * Update docs * Remove unnecessary attribute merging * Call traces_sampler only for root spans * Reuse sampling decision logic * Add test for handling invalid traces_sampler return values * Add tests for SamplingContext access functions --- .dialyzer_ignore.exs | 3 +- lib/sentry/config.ex | 60 +++++- lib/sentry/opentelemetry/sampler.ex | 72 ++++++- lib/sentry/sampling_context.ex | 57 +++++ test/sentry/config_traces_sampler_test.exs | 81 ++++++++ test/sentry/opentelemetry/sampler_test.exs | 230 ++++++++++++++++++++- test/sentry/sampling_context_test.exs | 187 +++++++++++++++++ 7 files changed, 681 insertions(+), 9 deletions(-) create mode 100644 lib/sentry/sampling_context.ex create mode 100644 test/sentry/config_traces_sampler_test.exs create mode 100644 test/sentry/sampling_context_test.exs diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 53745216..f0a3b9e3 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -1,4 +1,5 @@ [ {"test/support/example_plug_application.ex"}, - {"test/support/test_helpers.ex"} + {"test/support/test_helpers.ex"}, + {"lib/sentry/opentelemetry/sampler.ex", :pattern_match, 1} ] diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index 7d21873d..8987be4e 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -1,6 +1,13 @@ defmodule Sentry.Config do @moduledoc false + @typedoc """ + A function that determines the sample rate for transaction events. + + The function receives a sampling context map and should return a boolean or a float between `0.0` and `1.0`. + """ + @type traces_sampler_function :: (map() -> boolean() | float()) | {module(), atom()} + integrations_schema = [ max_expected_check_in_time: [ type: :integer, @@ -158,6 +165,34 @@ defmodule Sentry.Config do for guides on how to set it up. """ ], + traces_sampler: [ + type: {:custom, __MODULE__, :__validate_traces_sampler__, []}, + default: nil, + type_doc: "`t:traces_sampler_function/0` or `nil`", + doc: """ + A function that determines the sample rate for transaction events. This function + receives a sampling context struct and should return a boolean or a float between `0.0` and `1.0`. + + The sampling context contains: + - `:parent_sampled` - boolean indicating if the parent trace span was sampled (nil if no parent) + - `:transaction_context` - map with transaction information (name, op, etc.) + + If both `:traces_sampler` and `:traces_sample_rate` are configured, `:traces_sampler` takes precedence. + + Example: + ```elixir + traces_sampler: fn sampling_context -> + case sampling_context.transaction_context.op do + "http.server" -> 0.1 # Sample 10% of HTTP requests + "db.query" -> 0.01 # Sample 1% of database queries + _ -> false # Don't sample other operations + end + end + ``` + + This value is also used to determine if tracing is enabled: if it's not `nil`, tracing is enabled. + """ + ], included_environments: [ type: {:or, [{:in, [:all]}, {:list, {:or, [:atom, :string]}}]}, deprecated: "Use :dsn to control whether to send events to Sentry.", @@ -625,6 +660,9 @@ defmodule Sentry.Config do @spec traces_sample_rate() :: nil | float() def traces_sample_rate, do: fetch!(:traces_sample_rate) + @spec traces_sampler() :: traces_sampler_function() | nil + def traces_sampler, do: get(:traces_sampler) + @spec hackney_opts() :: keyword() def hackney_opts, do: fetch!(:hackney_opts) @@ -663,7 +701,7 @@ defmodule Sentry.Config do def integrations, do: fetch!(:integrations) @spec tracing?() :: boolean() - def tracing?, do: not is_nil(fetch!(:traces_sample_rate)) + def tracing?, do: not is_nil(fetch!(:traces_sample_rate)) or not is_nil(get(:traces_sampler)) @spec put_config(atom(), term()) :: :ok def put_config(key, value) when is_atom(key) do @@ -773,6 +811,26 @@ defmodule Sentry.Config do end end + def __validate_traces_sampler__(nil), do: {:ok, nil} + + def __validate_traces_sampler__(fun) when is_function(fun, 1) do + {:ok, fun} + end + + def __validate_traces_sampler__({module, function}) + when is_atom(module) and is_atom(function) do + if function_exported?(module, function, 1) do + {:ok, {module, function}} + else + {:error, "function #{module}.#{function}/1 is not exported"} + end + end + + def __validate_traces_sampler__(other) do + {:error, + "expected :traces_sampler to be nil, a function with arity 1, or a {module, function} tuple, got: #{inspect(other)}"} + end + def __validate_json_library__(nil) do {:error, "nil is not a valid value for the :json_library option"} end diff --git a/lib/sentry/opentelemetry/sampler.ex b/lib/sentry/opentelemetry/sampler.ex index 2c7d6087..f37de5c3 100644 --- a/lib/sentry/opentelemetry/sampler.ex +++ b/lib/sentry/opentelemetry/sampler.ex @@ -4,6 +4,9 @@ if Code.ensure_loaded?(:otel_sampler) do alias OpenTelemetry.{Span, Tracer} alias Sentry.ClientReport + alias SamplingContext + + require Logger @behaviour :otel_sampler @@ -24,27 +27,34 @@ if Code.ensure_loaded?(:otel_sampler) do @impl true def should_sample( ctx, - _trace_id, + trace_id, _links, span_name, - _span_kind, - _attributes, + span_kind, + attributes, config ) do result = if span_name in config[:drop] do {:drop, [], []} else - sample_rate = Sentry.Config.traces_sample_rate() + traces_sampler = Sentry.Config.traces_sampler() + traces_sample_rate = Sentry.Config.traces_sample_rate() case get_trace_sampling_decision(ctx) do {:inherit, trace_sampled, tracestate} -> decision = if trace_sampled, do: :record_and_sample, else: :drop - {decision, [], tracestate} :no_trace -> - make_sampling_decision(sample_rate) + if traces_sampler do + sampling_context = + build_sampling_context(nil, span_name, span_kind, attributes, trace_id) + + make_sampler_decision(traces_sampler, sampling_context) + else + make_sampling_decision(traces_sample_rate) + end end end @@ -121,6 +131,56 @@ if Code.ensure_loaded?(:otel_sampler) do end end + defp build_sampling_context(parent_sampled, span_name, _span_kind, attributes, trace_id) do + transaction_context = %{ + name: span_name, + op: span_name, + trace_id: trace_id, + attributes: attributes + } + + sampling_context = %SamplingContext{ + transaction_context: transaction_context, + parent_sampled: parent_sampled + } + + sampling_context + end + + defp make_sampler_decision(traces_sampler, sampling_context) do + try do + result = call_traces_sampler(traces_sampler, sampling_context) + sample_rate = normalize_sampler_result(result) + + if is_float(sample_rate) and sample_rate >= 0.0 and sample_rate <= 1.0 do + make_sampling_decision(sample_rate) + else + Logger.warning( + "traces_sampler function returned an invalid sample rate: #{inspect(sample_rate)}" + ) + + make_sampling_decision(0.0) + end + rescue + error -> + Logger.warning("traces_sampler function failed: #{inspect(error)}") + + make_sampling_decision(0.0) + end + end + + defp call_traces_sampler(fun, sampling_context) when is_function(fun, 1) do + fun.(sampling_context) + end + + defp call_traces_sampler({module, function}, sampling_context) do + apply(module, function, [sampling_context]) + end + + defp normalize_sampler_result(true), do: 1.0 + defp normalize_sampler_result(false), do: 0.0 + defp normalize_sampler_result(rate), do: rate + defp record_discarded_transaction() do ClientReport.Sender.record_discarded_events(:sample_rate, "transaction") end diff --git a/lib/sentry/sampling_context.ex b/lib/sentry/sampling_context.ex new file mode 100644 index 00000000..a64f3edf --- /dev/null +++ b/lib/sentry/sampling_context.ex @@ -0,0 +1,57 @@ +defmodule SamplingContext do + @moduledoc """ + The struct for the **sampling_context** that is passed to `traces_sampler`. + + This is set up via `Sentry.OpenTelemetry.Sampler`. + + See also . + """ + + @moduledoc since: "11.0.0" + + @typedoc """ + The sampling context struct that contains information needed for sampling decisions. + + This matches the structure used in the Python SDK's create_sampling_context function. + """ + @type t :: %__MODULE__{ + transaction_context: %{ + name: String.t() | nil, + op: String.t(), + trace_id: String.t(), + attributes: map() + }, + parent_sampled: boolean() | nil + } + + @enforce_keys [:transaction_context, :parent_sampled] + defstruct [:transaction_context, :parent_sampled] + + @behaviour Access + + @impl Access + def fetch(struct, key) do + case Map.fetch(struct, key) do + {:ok, value} -> {:ok, value} + :error -> :error + end + end + + @impl Access + def get_and_update(struct, key, function) do + current_value = Map.get(struct, key) + + case function.(current_value) do + {get_value, update_value} -> + {get_value, Map.put(struct, key, update_value)} + + :pop -> + {current_value, Map.delete(struct, key)} + end + end + + @impl Access + def pop(struct, key) do + {Map.get(struct, key), Map.delete(struct, key)} + end +end diff --git a/test/sentry/config_traces_sampler_test.exs b/test/sentry/config_traces_sampler_test.exs new file mode 100644 index 00000000..0caf78b5 --- /dev/null +++ b/test/sentry/config_traces_sampler_test.exs @@ -0,0 +1,81 @@ +defmodule Sentry.ConfigTracesSamplerTest do + use ExUnit.Case, async: true + + import Sentry.TestHelpers + + describe "traces_sampler configuration validation" do + defmodule TestSampler do + def sample(_context), do: 0.5 + end + + test "accepts nil" do + assert :ok = put_test_config(traces_sampler: nil) + assert Sentry.Config.traces_sampler() == nil + end + + test "accepts function with arity 1" do + fun = fn _context -> 0.5 end + assert :ok = put_test_config(traces_sampler: fun) + assert Sentry.Config.traces_sampler() == fun + end + + test "accepts MFA tuple with exported function" do + assert :ok = put_test_config(traces_sampler: {TestSampler, :sample}) + assert Sentry.Config.traces_sampler() == {TestSampler, :sample} + end + + test "rejects MFA tuple with non-exported function" do + assert_raise ArgumentError, ~r/function.*is not exported/, fn -> + put_test_config(traces_sampler: {TestSampler, :non_existent}) + end + end + + test "rejects function with wrong arity" do + fun = fn -> 0.5 end + + assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn -> + put_test_config(traces_sampler: fun) + end + end + + test "rejects invalid types" do + assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn -> + put_test_config(traces_sampler: "invalid") + end + + assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn -> + put_test_config(traces_sampler: 123) + end + + assert_raise ArgumentError, ~r/expected :traces_sampler to be/, fn -> + put_test_config(traces_sampler: []) + end + end + end + + describe "tracing? function" do + test "returns true when traces_sample_rate is set" do + put_test_config(traces_sample_rate: 0.5, traces_sampler: nil) + + assert Sentry.Config.tracing?() + end + + test "returns true when traces_sampler is set" do + put_test_config(traces_sample_rate: nil, traces_sampler: fn _ -> 0.5 end) + + assert Sentry.Config.tracing?() + end + + test "returns true when both are set" do + put_test_config(traces_sample_rate: 0.5, traces_sampler: fn _ -> 0.5 end) + + assert Sentry.Config.tracing?() + end + + test "returns false when neither is set" do + put_test_config(traces_sample_rate: nil, traces_sampler: nil) + + refute Sentry.Config.tracing?() + end + end +end diff --git a/test/sentry/opentelemetry/sampler_test.exs b/test/sentry/opentelemetry/sampler_test.exs index 9b9f2ee7..3b37c5b0 100644 --- a/test/sentry/opentelemetry/sampler_test.exs +++ b/test/sentry/opentelemetry/sampler_test.exs @@ -3,6 +3,7 @@ defmodule Sentry.Opentelemetry.SamplerTest do alias Sentry.OpenTelemetry.Sampler alias Sentry.ClientReport + alias SamplingContext import Sentry.TestHelpers @@ -182,7 +183,6 @@ defmodule Sentry.Opentelemetry.SamplerTest do test "all spans in trace inherit sampling decision to sample when trace was sampled" do trace_id = 12_345_678_901_234_567_890_123_456_789_012 - # Simulate existing trace context with sample decision trace_tracestate = [ {"sentry-sample_rate", "1.0"}, {"sentry-sample_rand", "0.5"}, @@ -295,4 +295,232 @@ defmodule Sentry.Opentelemetry.SamplerTest do assert sampled_str in ["true", "false"] end end + + describe "traces_sampler functionality" do + test "uses traces_sampler when configured" do + sampler_fun = fn _sampling_context -> 0.5 end + put_test_config(traces_sampler: sampler_fun) + + test_ctx = create_test_span_context() + + {decision, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", :server, %{}, drop: []) + + assert decision in [:record_and_sample, :drop] + assert {"sentry-sample_rate", "0.5"} in tracestate + assert {"sentry-sampled", _} = List.keyfind(tracestate, "sentry-sampled", 0) + end + + test "traces_sampler receives correct sampling context" do + {:ok, received_context} = Agent.start_link(fn -> nil end) + + sampler_fun = fn sampling_context -> + Agent.update(received_context, fn _ -> sampling_context end) + true + end + + put_test_config(traces_sampler: sampler_fun) + + test_ctx = create_test_span_context() + attributes = %{"http.method" => "GET", "http.url" => "http://example.com"} + + Sampler.should_sample(test_ctx, 123, nil, "GET /users", :server, attributes, drop: []) + + context = Agent.get(received_context, & &1) + + assert context[:parent_sampled] == nil + assert context[:transaction_context][:name] == "GET /users" + assert context[:transaction_context][:op] == "GET /users" + assert context[:transaction_context][:trace_id] == 123 + assert context[:transaction_context][:attributes] == attributes + + Agent.stop(received_context) + end + + test "traces_sampler can return boolean values" do + put_test_config(traces_sampler: fn _ -> true end) + test_ctx = create_test_span_context() + + assert {:record_and_sample, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, %{}, drop: []) + + assert {"sentry-sampled", "true"} in tracestate + + put_test_config(traces_sampler: fn _ -> false end) + + assert {:drop, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, %{}, drop: []) + + assert {"sentry-sampled", "false"} in tracestate + end + + test "traces_sampler can return float values" do + put_test_config(traces_sampler: fn _ -> 0.75 end) + + test_ctx = create_test_span_context() + + {decision, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, %{}, drop: []) + + assert decision in [:record_and_sample, :drop] + assert {"sentry-sample_rate", "0.75"} in tracestate + end + + test "traces_sampler takes precedence over traces_sample_rate" do + put_test_config(traces_sample_rate: 1.0, traces_sampler: fn _ -> false end) + + test_ctx = create_test_span_context() + + assert {:drop, [], _tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, %{}, drop: []) + end + + test "child spans inherit parent sampling decision without calling traces_sampler" do + {:ok, sampler_call_count} = Agent.start_link(fn -> 0 end) + + sampler_fun = fn _sampling_context -> + Agent.update(sampler_call_count, &(&1 + 1)) + false + end + + put_test_config(traces_sampler: sampler_fun) + + trace_tracestate = [ + {"sentry-sample_rate", "1.0"}, + {"sentry-sample_rand", "0.5"}, + {"sentry-sampled", "true"} + ] + + existing_span_ctx = create_span_context_with_tracestate(123, trace_tracestate) + + ctx = :otel_ctx.new() + ctx_with_span = :otel_tracer.set_current_span(ctx, existing_span_ctx) + token = :otel_ctx.attach(ctx_with_span) + + try do + result = + Sampler.should_sample(ctx_with_span, 123, nil, "child span", nil, %{}, drop: []) + + assert {:record_and_sample, [], returned_tracestate} = result + assert returned_tracestate == trace_tracestate + + call_count = Agent.get(sampler_call_count, & &1) + assert call_count == 0 + after + :otel_ctx.detach(token) + Agent.stop(sampler_call_count) + end + end + + test "traces_sampler is only called for root spans" do + {:ok, sampler_call_count} = Agent.start_link(fn -> 0 end) + + sampler_fun = fn _sampling_context -> + Agent.update(sampler_call_count, &(&1 + 1)) + true + end + + put_test_config(traces_sampler: sampler_fun) + + test_ctx = create_test_span_context() + + result = Sampler.should_sample(test_ctx, 123, nil, "root span", nil, %{}, drop: []) + + assert {:record_and_sample, [], _tracestate} = result + + call_count = Agent.get(sampler_call_count, & &1) + assert call_count == 1 + + Agent.stop(sampler_call_count) + end + + test "handles traces_sampler errors gracefully" do + put_test_config(traces_sampler: fn _ -> raise "sampler error" end) + + test_ctx = create_test_span_context() + + assert {:drop, [], _tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, %{}, drop: []) + end + + test "handles invalid traces_sampler return values gracefully" do + test_cases = [ + -0.5, + 1.5, + 2.0, + "invalid", + :invalid, + %{invalid: true}, + [1, 2, 3], + nil + ] + + Enum.each(test_cases, fn invalid_value -> + put_test_config(traces_sampler: fn _ -> invalid_value end) + + test_ctx = create_test_span_context() + + result = Sampler.should_sample(test_ctx, 123, nil, "test span", nil, %{}, drop: []) + + assert {:drop, [], tracestate} = result + assert {"sentry-sample_rate", "0.0"} in tracestate + assert {"sentry-sampled", "false"} in tracestate + end) + end + + test "supports MFA tuple for traces_sampler" do + defmodule TestSampler do + def sample(_sampling_context), do: 0.25 + end + + put_test_config(traces_sampler: {TestSampler, :sample}) + + test_ctx = create_test_span_context() + + {decision, [], tracestate} = + Sampler.should_sample(test_ctx, 123, nil, "test span", nil, %{}, drop: []) + + assert decision in [:record_and_sample, :drop] + assert {"sentry-sample_rate", "0.25"} in tracestate + end + + test "uses span name as operation and passes attributes" do + {:ok, received_context} = Agent.start_link(fn -> nil end) + + sampler_fun = fn sampling_context -> + Agent.update(received_context, fn _ -> sampling_context end) + true + end + + put_test_config(traces_sampler: sampler_fun) + + test_ctx = create_test_span_context() + + http_attributes = %{"http.method" => "POST"} + + Sampler.should_sample(test_ctx, 123, nil, "POST /api", :server, http_attributes, drop: []) + + context = Agent.get(received_context, & &1) + assert context[:transaction_context][:op] == "POST /api" + assert context[:transaction_context][:attributes] == http_attributes + + db_attributes = %{"db.system" => "postgresql"} + + Sampler.should_sample(test_ctx, 124, nil, "SELECT users", :client, db_attributes, drop: []) + + context = Agent.get(received_context, & &1) + assert context[:transaction_context][:op] == "SELECT users" + assert context[:transaction_context][:attributes] == db_attributes + + oban_attributes = %{"messaging.system" => :oban} + + Sampler.should_sample(test_ctx, 125, nil, "MyWorker", :consumer, oban_attributes, drop: []) + + context = Agent.get(received_context, & &1) + assert context[:transaction_context][:op] == "MyWorker" + assert context[:transaction_context][:attributes] == oban_attributes + + Agent.stop(received_context) + end + end end diff --git a/test/sentry/sampling_context_test.exs b/test/sentry/sampling_context_test.exs new file mode 100644 index 00000000..62f891d2 --- /dev/null +++ b/test/sentry/sampling_context_test.exs @@ -0,0 +1,187 @@ +defmodule Sentry.Opentelemetry.SamplingContextTest do + use Sentry.Case, async: true + + alias SamplingContext + + describe "Access functions" do + test "fetch/2 returns {:ok, value} for existing keys" do + transaction_context = %{ + name: "GET /users", + op: "http.server", + trace_id: 123, + attributes: %{"http.method" => "GET"} + } + + sampling_context = %SamplingContext{ + transaction_context: transaction_context, + parent_sampled: true + } + + assert {:ok, ^transaction_context} = + SamplingContext.fetch(sampling_context, :transaction_context) + + assert {:ok, true} = SamplingContext.fetch(sampling_context, :parent_sampled) + end + + test "fetch/2 returns :error for non-existing keys" do + sampling_context = %SamplingContext{ + transaction_context: %{name: "test", op: "test", trace_id: 123, attributes: %{}}, + parent_sampled: nil + } + + assert :error = SamplingContext.fetch(sampling_context, :non_existing_key) + assert :error = SamplingContext.fetch(sampling_context, :invalid) + end + + test "get_and_update/3 updates existing keys" do + transaction_context = %{ + name: "GET /users", + op: "http.server", + trace_id: 123, + attributes: %{"http.method" => "GET"} + } + + sampling_context = %SamplingContext{ + transaction_context: transaction_context, + parent_sampled: false + } + + update_fun = fn current_value -> + {current_value, !current_value} + end + + {old_value, updated_context} = + SamplingContext.get_and_update(sampling_context, :parent_sampled, update_fun) + + assert old_value == false + assert updated_context.parent_sampled == true + assert updated_context.transaction_context == transaction_context + end + + test "get_and_update/3 handles :pop operation" do + sampling_context = %SamplingContext{ + transaction_context: %{name: "test", op: "test", trace_id: 123, attributes: %{}}, + parent_sampled: true + } + + pop_fun = fn _current_value -> :pop end + + {old_value, updated_context} = + SamplingContext.get_and_update(sampling_context, :parent_sampled, pop_fun) + + assert old_value == true + refute Map.has_key?(updated_context, :parent_sampled) + end + + test "get_and_update/3 works with non-existing keys" do + sampling_context = %SamplingContext{ + transaction_context: %{name: "test", op: "test", trace_id: 123, attributes: %{}}, + parent_sampled: nil + } + + update_fun = fn current_value -> + {current_value, "new_value"} + end + + {old_value, updated_context} = + SamplingContext.get_and_update(sampling_context, :new_key, update_fun) + + assert old_value == nil + assert Map.get(updated_context, :new_key) == "new_value" + end + + test "pop/2 removes existing keys and returns value" do + transaction_context = %{ + name: "POST /api", + op: "http.server", + trace_id: 456, + attributes: %{"http.method" => "POST"} + } + + sampling_context = %SamplingContext{ + transaction_context: transaction_context, + parent_sampled: true + } + + {popped_value, updated_context} = SamplingContext.pop(sampling_context, :parent_sampled) + + assert popped_value == true + refute Map.has_key?(updated_context, :parent_sampled) + assert updated_context.transaction_context == transaction_context + end + + test "pop/2 returns nil for non-existing keys" do + sampling_context = %SamplingContext{ + transaction_context: %{name: "test", op: "test", trace_id: 123, attributes: %{}}, + parent_sampled: nil + } + + {popped_value, updated_context} = SamplingContext.pop(sampling_context, :non_existing_key) + + assert popped_value == nil + assert updated_context == sampling_context + end + + test "Access behavior works with bracket notation" do + transaction_context = %{ + name: "DELETE /resource", + op: "http.server", + trace_id: 789, + attributes: %{"http.method" => "DELETE"} + } + + sampling_context = %SamplingContext{ + transaction_context: transaction_context, + parent_sampled: false + } + + # Test bracket access for reading + assert sampling_context[:transaction_context] == transaction_context + assert sampling_context[:parent_sampled] == false + assert sampling_context[:non_existing] == nil + + # Test get_in/2 + assert get_in(sampling_context, [:transaction_context, :name]) == "DELETE /resource" + + assert get_in(sampling_context, [:transaction_context, :attributes, "http.method"]) == + "DELETE" + end + + test "Access behavior works with put_in/3" do + sampling_context = %SamplingContext{ + transaction_context: %{name: "test", op: "test", trace_id: 123, attributes: %{}}, + parent_sampled: nil + } + + updated_context = put_in(sampling_context[:parent_sampled], true) + + assert updated_context.parent_sampled == true + assert updated_context.transaction_context == sampling_context.transaction_context + end + + test "Access behavior works with update_in/3" do + transaction_context = %{ + name: "PUT /update", + op: "http.server", + trace_id: 999, + attributes: %{"http.method" => "PUT", "http.status_code" => 200} + } + + sampling_context = %SamplingContext{ + transaction_context: transaction_context, + parent_sampled: false + } + + updated_context = + update_in(sampling_context[:transaction_context][:attributes], fn attrs -> + Map.put(attrs, "http.status_code", 404) + end) + + assert get_in(updated_context, [:transaction_context, :attributes, "http.status_code"]) == + 404 + + assert get_in(updated_context, [:transaction_context, :attributes, "http.method"]) == "PUT" + assert updated_context.parent_sampled == false + end + end +end From ff7eccc3d224aec04246b2b22f1bc1dada75f2f7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 17 Jun 2025 12:53:15 +0200 Subject: [PATCH 4/5] Add basic integration tests for OpenTelemetry (#912) --- .../phoenix_app/config/config.exs | 6 + test_integrations/phoenix_app/config/dev.exs | 22 +++ test_integrations/phoenix_app/config/test.exs | 18 ++- .../phoenix_app/lib/phoenix_app/accounts.ex | 104 +++++++++++++ .../lib/phoenix_app/accounts/user.ex | 18 +++ .../lib/phoenix_app/application.ex | 33 ++++- .../phoenix_app/lib/phoenix_app/repo.ex | 5 + .../lib/phoenix_app/workers/test_worker.ex | 21 +++ .../controllers/page_controller.ex | 20 ++- .../lib/phoenix_app_web/endpoint.ex | 1 - .../phoenix_app_web/live/test_worker_live.ex | 94 ++++++++++++ .../live/test_worker_live.html.heex | 103 +++++++++++++ .../live/user_live/form_component.ex | 83 +++++++++++ .../phoenix_app_web/live/user_live/index.ex | 47 ++++++ .../live/user_live/index.html.heex | 42 ++++++ .../phoenix_app_web/live/user_live/show.ex | 21 +++ .../live/user_live/show.html.heex | 27 ++++ .../phoenix_app/lib/phoenix_app_web/router.ex | 10 ++ test_integrations/phoenix_app/mix.exs | 24 ++- test_integrations/phoenix_app/mix.lock | 16 ++ .../20240926155911_create_users.exs | 12 ++ .../migrations/20241213222834_add_oban.exs | 11 ++ .../test/phoenix_app/oban_test.exs | 45 ++++++ .../test/phoenix_app/repo_test.exs | 28 ++++ .../controllers/exception_test.exs | 17 +-- .../controllers/transaction_test.exs | 64 ++++++++ .../phoenix_app_web/live/user_live_test.exs | 140 ++++++++++++++++++ .../phoenix_app/test/support/data_case.ex | 26 ++-- .../support/fixtures/accounts_fixtures.ex | 21 +++ .../phoenix_app/test/test_helper.exs | 2 +- 30 files changed, 1038 insertions(+), 43 deletions(-) create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/accounts.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/accounts/user.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/repo.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/workers/test_worker.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/form_component.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.html.heex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.html.heex create mode 100644 test_integrations/phoenix_app/priv/repo/migrations/20240926155911_create_users.exs create mode 100644 test_integrations/phoenix_app/priv/repo/migrations/20241213222834_add_oban.exs create mode 100644 test_integrations/phoenix_app/test/phoenix_app/oban_test.exs create mode 100644 test_integrations/phoenix_app/test/phoenix_app/repo_test.exs create mode 100644 test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs create mode 100644 test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs create mode 100644 test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex diff --git a/test_integrations/phoenix_app/config/config.exs b/test_integrations/phoenix_app/config/config.exs index a0ce0afe..68901111 100644 --- a/test_integrations/phoenix_app/config/config.exs +++ b/test_integrations/phoenix_app/config/config.exs @@ -8,6 +8,7 @@ import Config config :phoenix_app, + ecto_repos: [PhoenixApp.Repo], generators: [timestamp_type: :utc_datetime] # Configures the endpoint @@ -59,6 +60,11 @@ config :logger, :console, config :phoenix, :json_library, if(Code.ensure_loaded?(JSON), do: JSON, else: Jason) +config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} + +config :opentelemetry, + sampler: {Sentry.OpenTelemetry.Sampler, [drop: ["Elixir.Oban.Stager process"]]} + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/test_integrations/phoenix_app/config/dev.exs b/test_integrations/phoenix_app/config/dev.exs index 9506d05c..8dc26871 100644 --- a/test_integrations/phoenix_app/config/dev.exs +++ b/test_integrations/phoenix_app/config/dev.exs @@ -1,5 +1,10 @@ import Config +# Configure your database +config :phoenix_app, PhoenixApp.Repo, + adapter: Ecto.Adapters.SQLite3, + database: "db/dev.sqlite3" + # For development, we disable any cache and enable # debugging and code reloading. # @@ -73,3 +78,20 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +dsn = + if System.get_env("SENTRY_LOCAL"), + do: System.get_env("SENTRY_DSN_LOCAL"), + else: System.get_env("SENTRY_DSN") + +config :sentry, + dsn: dsn, + environment_name: :dev, + enable_source_code_context: true, + send_result: :sync, + traces_sample_rate: 1.0 + +config :phoenix_app, Oban, + repo: PhoenixApp.Repo, + engine: Oban.Engines.Lite, + queues: [default: 10, background: 5] diff --git a/test_integrations/phoenix_app/config/test.exs b/test_integrations/phoenix_app/config/test.exs index f845417b..39f25e84 100644 --- a/test_integrations/phoenix_app/config/test.exs +++ b/test_integrations/phoenix_app/config/test.exs @@ -1,5 +1,11 @@ import Config +# Configure your database +config :phoenix_app, PhoenixApp.Repo, + adapter: Ecto.Adapters.SQLite3, + pool: Ecto.Adapters.SQL.Sandbox, + database: "db/test.sqlite3" + # We don't run a server during test. If one is required, # you can enable the server option below. config :phoenix_app, PhoenixAppWeb.Endpoint, @@ -24,11 +30,17 @@ config :phoenix_live_view, enable_expensive_runtime_checks: true config :sentry, - dsn: "http://public:secret@localhost:8080/1", - environment_name: Mix.env(), + dsn: nil, + environment_name: :dev, enable_source_code_context: true, root_source_code_paths: [File.cwd!()], test_mode: true, - send_result: :sync + send_result: :sync, + traces_sample_rate: 1.0 config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} + +config :phoenix_app, Oban, + repo: PhoenixApp.Repo, + engine: Oban.Engines.Lite, + queues: [default: 10, background: 5] diff --git a/test_integrations/phoenix_app/lib/phoenix_app/accounts.ex b/test_integrations/phoenix_app/lib/phoenix_app/accounts.ex new file mode 100644 index 00000000..2b626dad --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app/accounts.ex @@ -0,0 +1,104 @@ +defmodule PhoenixApp.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias PhoenixApp.Repo + + alias PhoenixApp.Accounts.User + + @doc """ + Returns the list of users. + + ## Examples + + iex> list_users() + [%User{}, ...] + + """ + def list_users do + Repo.all(User) + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + @doc """ + Creates a user. + + ## Examples + + iex> create_user(%{field: value}) + {:ok, %User{}} + + iex> create_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_user(attrs \\ %{}) do + %User{} + |> User.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a user. + + ## Examples + + iex> update_user(user, %{field: new_value}) + {:ok, %User{}} + + iex> update_user(user, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_user(%User{} = user, attrs) do + user + |> User.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a user. + + ## Examples + + iex> delete_user(user) + {:ok, %User{}} + + iex> delete_user(user) + {:error, %Ecto.Changeset{}} + + """ + def delete_user(%User{} = user) do + Repo.delete(user) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user(%User{} = user, attrs \\ %{}) do + User.changeset(user, attrs) + end +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app/accounts/user.ex b/test_integrations/phoenix_app/lib/phoenix_app/accounts/user.ex new file mode 100644 index 00000000..21fc3552 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app/accounts/user.ex @@ -0,0 +1,18 @@ +defmodule PhoenixApp.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :name, :string + field :age, :integer + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(user, attrs) do + user + |> cast(attrs, [:name, :age]) + |> validate_required([:name, :age]) + end +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app/application.ex b/test_integrations/phoenix_app/lib/phoenix_app/application.ex index b97f81ba..ff132cb1 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app/application.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app/application.ex @@ -7,14 +7,28 @@ defmodule PhoenixApp.Application do @impl true def start(_type, _args) do + :ok = Application.ensure_started(:inets) + + :logger.add_handler(:my_sentry_handler, Sentry.LoggerHandler, %{ + config: %{metadata: [:file, :line]} + }) + + OpentelemetryBandit.setup() + OpentelemetryPhoenix.setup(adapter: :bandit) + OpentelemetryOban.setup() + OpentelemetryEcto.setup([:phoenix_app, :repo], db_statement: :enabled) + children = [ PhoenixAppWeb.Telemetry, + PhoenixApp.Repo, + {Ecto.Migrator, + repos: Application.fetch_env!(:phoenix_app, :ecto_repos), skip: skip_migrations?()}, {DNSCluster, query: Application.get_env(:phoenix_app, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: PhoenixApp.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: PhoenixApp.Finch}, - # Start a worker by calling: PhoenixApp.Worker.start_link(arg) - # {PhoenixApp.Worker, arg}, + # Start Oban + {Oban, Application.fetch_env!(:phoenix_app, Oban)}, # Start to serve requests, typically the last entry PhoenixAppWeb.Endpoint ] @@ -25,12 +39,15 @@ defmodule PhoenixApp.Application do Supervisor.start_link(children, opts) end - # TODO: Uncomment if we ever move the endpoint from test/support to the phoenix_app dir # Tell Phoenix to update the endpoint configuration # whenever the application is updated. - # @impl true - # def config_change(changed, _new, removed) do - # PhoenixAppWeb.Endpoint.config_change(changed, removed) - # :ok - # end + @impl true + def config_change(changed, _new, removed) do + PhoenixAppWeb.Endpoint.config_change(changed, removed) + :ok + end + + defp skip_migrations?() do + System.get_env("RELEASE_NAME") != nil + end end diff --git a/test_integrations/phoenix_app/lib/phoenix_app/repo.ex b/test_integrations/phoenix_app/lib/phoenix_app/repo.ex new file mode 100644 index 00000000..3976eb3b --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app/repo.ex @@ -0,0 +1,5 @@ +defmodule PhoenixApp.Repo do + use Ecto.Repo, + otp_app: :phoenix_app, + adapter: Ecto.Adapters.SQLite3 +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app/workers/test_worker.ex b/test_integrations/phoenix_app/lib/phoenix_app/workers/test_worker.ex new file mode 100644 index 00000000..be57ffaf --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app/workers/test_worker.ex @@ -0,0 +1,21 @@ +defmodule PhoenixApp.Workers.TestWorker do + use Oban.Worker + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"sleep_time" => sleep_time, "should_fail" => should_fail}}) do + # Simulate some work + Process.sleep(sleep_time) + + if should_fail do + raise "Simulated failure in test worker" + else + :ok + end + end + + def perform(%Oban.Job{args: %{"sleep_time" => sleep_time}}) do + # Simulate some work + Process.sleep(sleep_time) + :ok + end +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex index b51d6b3c..dbc7812b 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex @@ -1,13 +1,29 @@ defmodule PhoenixAppWeb.PageController do use PhoenixAppWeb, :controller + require OpenTelemetry.Tracer, as: Tracer + + alias PhoenixApp.{Repo, User} + def home(conn, _params) do - # The home page is often custom made, - # so skip the default app layout. render(conn, :home, layout: false) end def exception(_conn, _params) do raise "Test exception" end + + def transaction(conn, _params) do + Tracer.with_span "test_span" do + :timer.sleep(100) + end + + render(conn, :home, layout: false) + end + + def users(conn, _params) do + Repo.all(User) |> Enum.map(& &1.name) + + render(conn, :home, layout: false) + end end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex index c1817a4e..cbc6c40a 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex @@ -35,7 +35,6 @@ defmodule PhoenixAppWeb.Endpoint do socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket plug Phoenix.LiveReloader plug Phoenix.CodeReloader - plug Phoenix.Ecto.CheckRepoStatus, otp_app: :phoenix_app end plug Phoenix.LiveDashboard.RequestLogger, diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex new file mode 100644 index 00000000..0ba8562a --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex @@ -0,0 +1,94 @@ +defmodule PhoenixAppWeb.TestWorkerLive do + use PhoenixAppWeb, :live_view + + alias PhoenixApp.Workers.TestWorker + + @impl true + def mount(_params, _session, socket) do + socket = + assign(socket, + form: to_form(%{"sleep_time" => 1000, "should_fail" => false, "queue" => "default"}), + auto_form: to_form(%{"job_count" => 5}), + jobs: list_jobs() + ) + + if connected?(socket) do + # Poll for job updates every second + :timer.send_interval(1000, self(), :update_jobs) + end + + {:ok, socket} + end + + @impl true + def handle_event("schedule", %{"test_job" => params}, socket) do + sleep_time = String.to_integer(params["sleep_time"]) + should_fail = params["should_fail"] == "true" + queue = params["queue"] + + case schedule_job(sleep_time, should_fail, queue) do + {:ok, _job} -> + {:noreply, + socket + |> put_flash(:info, "Job scheduled successfully!") + |> assign(jobs: list_jobs())} + + {:error, changeset} -> + {:noreply, + socket + |> put_flash(:error, "Error scheduling job: #{inspect(changeset.errors)}")} + end + end + + @impl true + def handle_event("auto_schedule", %{"auto" => %{"job_count" => count}}, socket) do + job_count = String.to_integer(count) + + results = + Enum.map(1..job_count, fn _ -> + sleep_time = Enum.random(500..5000) + should_fail = Enum.random([true, false]) + queue = Enum.random(["default", "background"]) + + schedule_job(sleep_time, should_fail, queue) + end) + + failed_count = Enum.count(results, &match?({:error, _}, &1)) + success_count = job_count - failed_count + + socket = + socket + |> put_flash(:info, "Scheduled #{success_count} jobs successfully!") + |> assign(jobs: list_jobs()) + + if failed_count > 0 do + socket = put_flash(socket, :error, "Failed to schedule #{failed_count} jobs") + {:noreply, socket} + else + {:noreply, socket} + end + end + + @impl true + def handle_info(:update_jobs, socket) do + {:noreply, assign(socket, jobs: list_jobs())} + end + + defp schedule_job(sleep_time, should_fail, queue) do + TestWorker.new( + %{"sleep_time" => sleep_time, "should_fail" => should_fail}, + queue: queue + ) + |> Oban.insert() + end + + defp list_jobs do + import Ecto.Query + + Oban.Job + |> where([j], j.worker == "PhoenixApp.Workers.TestWorker") + |> order_by([j], desc: j.inserted_at) + |> limit(10) + |> PhoenixApp.Repo.all() + end +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex new file mode 100644 index 00000000..d4f75595 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex @@ -0,0 +1,103 @@ +
+
+
+

Schedule Test Worker

+ +
+ <.form for={@form} phx-submit="schedule" class="space-y-6"> +
+ +
+ +
+
+ +
+ + +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+

Auto Schedule Multiple Jobs

+ +
+ <.form for={@auto_form} phx-submit="auto_schedule" class="space-y-6"> +
+ +
+ +
+

+ Jobs will be created with random sleep times (500-5000ms), random queues, and random failure states. +

+
+ +
+ +
+ +
+
+
+ +
+

Recent Jobs

+ +
+ + + + + + + + + + + + <%= for job <- @jobs do %> + + + + + + + + <% end %> + +
IDQueueStateAttemptArgs
<%= job.id %><%= job.queue %><%= job.state %><%= job.attempt %><%= inspect(job.args) %>
+
+
+
diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/form_component.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/form_component.ex new file mode 100644 index 00000000..622a6b05 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/form_component.ex @@ -0,0 +1,83 @@ +defmodule PhoenixAppWeb.UserLive.FormComponent do + use PhoenixAppWeb, :live_component + + alias PhoenixApp.Accounts + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + <%= @title %> + <:subtitle>Use this form to manage user records in your database. + + + <.simple_form + for={@form} + id="user-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:name]} type="text" label="Name" /> + <.input field={@form[:age]} type="number" label="Age" /> + <:actions> + <.button phx-disable-with="Saving...">Save User + + +
+ """ + end + + @impl true + def update(%{user: user} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Accounts.change_user(user)) + end)} + end + + @impl true + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user(socket.assigns.user, user_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"user" => user_params}, socket) do + save_user(socket, socket.assigns.action, user_params) + end + + defp save_user(socket, :edit, user_params) do + case Accounts.update_user(socket.assigns.user, user_params) do + {:ok, user} -> + notify_parent({:saved, user}) + + {:noreply, + socket + |> put_flash(:info, "User updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_user(socket, :new, user_params) do + case Accounts.create_user(user_params) do + {:ok, user} -> + notify_parent({:saved, user}) + + {:noreply, + socket + |> put_flash(:info, "User created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.ex new file mode 100644 index 00000000..4cbf8962 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.ex @@ -0,0 +1,47 @@ +defmodule PhoenixAppWeb.UserLive.Index do + use PhoenixAppWeb, :live_view + + alias PhoenixApp.Accounts + alias PhoenixApp.Accounts.User + + @impl true + def mount(_params, _session, socket) do + {:ok, stream(socket, :users, Accounts.list_users())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit User") + |> assign(:user, Accounts.get_user!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New User") + |> assign(:user, %User{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Users") + |> assign(:user, nil) + end + + @impl true + def handle_info({PhoenixAppWeb.UserLive.FormComponent, {:saved, user}}, socket) do + {:noreply, stream_insert(socket, :users, user)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + user = Accounts.get_user!(id) + {:ok, _} = Accounts.delete_user(user) + + {:noreply, stream_delete(socket, :users, user)} + end +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.html.heex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.html.heex new file mode 100644 index 00000000..33a964df --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.html.heex @@ -0,0 +1,42 @@ +<.header> + Listing Users + <:actions> + <.link patch={~p"/users/new"}> + <.button>New User + + + + +<.table + id="users" + rows={@streams.users} + row_click={fn {_id, user} -> JS.navigate(~p"/users/#{user}") end} +> + <:col :let={{_id, user}} label="Name"><%= user.name %> + <:col :let={{_id, user}} label="Age"><%= user.age %> + <:action :let={{_id, user}}> +
+ <.link navigate={~p"/users/#{user}"}>Show +
+ <.link patch={~p"/users/#{user}/edit"}>Edit + + <:action :let={{id, user}}> + <.link + phx-click={JS.push("delete", value: %{id: user.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal :if={@live_action in [:new, :edit]} id="user-modal" show on_cancel={JS.patch(~p"/users")}> + <.live_component + module={PhoenixAppWeb.UserLive.FormComponent} + id={@user.id || :new} + title={@page_title} + action={@live_action} + user={@user} + patch={~p"/users"} + /> + diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.ex new file mode 100644 index 00000000..eaa24470 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.ex @@ -0,0 +1,21 @@ +defmodule PhoenixAppWeb.UserLive.Show do + use PhoenixAppWeb, :live_view + + alias PhoenixApp.Accounts + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:user, Accounts.get_user!(id))} + end + + defp page_title(:show), do: "Show User" + defp page_title(:edit), do: "Edit User" +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.html.heex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.html.heex new file mode 100644 index 00000000..35b90bb2 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.html.heex @@ -0,0 +1,27 @@ +<.header> + User <%= @user.id %> + <:subtitle>This is a user record from your database. + <:actions> + <.link patch={~p"/users/#{@user}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit user + + + + +<.list> + <:item title="Name"><%= @user.name %> + <:item title="Age"><%= @user.age %> + + +<.back navigate={~p"/users"}>Back to users + +<.modal :if={@live_action == :edit} id="user-modal" show on_cancel={JS.patch(~p"/users/#{@user}")}> + <.live_component + module={PhoenixAppWeb.UserLive.FormComponent} + id={@user.id} + title={@page_title} + action={@live_action} + user={@user} + patch={~p"/users/#{@user}"} + /> + diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex index 409aeb27..ddf33edf 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex @@ -19,6 +19,16 @@ defmodule PhoenixAppWeb.Router do get "/", PageController, :home get "/exception", PageController, :exception + get "/transaction", PageController, :transaction + + live "/test-worker", TestWorkerLive + + live "/users", UserLive.Index, :index + live "/users/new", UserLive.Index, :new + live "/users/:id/edit", UserLive.Index, :edit + + live "/users/:id", UserLive.Show, :show + live "/users/:id/show/edit", UserLive.Show, :edit end # Other scopes may use custom stacks. diff --git a/test_integrations/phoenix_app/mix.exs b/test_integrations/phoenix_app/mix.exs index ad10a3da..52e6a1b4 100644 --- a/test_integrations/phoenix_app/mix.exs +++ b/test_integrations/phoenix_app/mix.exs @@ -36,10 +36,21 @@ defmodule PhoenixApp.MixProject do {:nimble_ownership, "~> 0.3.0 or ~> 1.0"}, {:postgrex, ">= 0.0.0"}, + {:ecto, "~> 3.12"}, + {:ecto_sql, "~> 3.12"}, + {:ecto_sqlite3, "~> 0.16"}, {:phoenix, "~> 1.7.14"}, {:phoenix_html, "~> 4.1"}, {:phoenix_live_view, "~> 1.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_ecto, "~> 4.6", optional: true}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.1.1", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, {:floki, ">= 0.30.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, @@ -53,14 +64,23 @@ defmodule PhoenixApp.MixProject do {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, {:bypass, "~> 2.1", only: :test}, - {:hackney, "~> 1.18", only: :test}, {:sentry, path: "../.."}, {:opentelemetry, "~> 1.5"}, {:opentelemetry_api, "~> 1.4"}, {:opentelemetry_exporter, "~> 1.0"}, - {:opentelemetry_semantic_conventions, "~> 1.27"} + {:opentelemetry_semantic_conventions, "~> 1.27"}, + {:opentelemetry_bandit, "~> 0.1"}, + {:opentelemetry_phoenix, "~> 2.0"}, + # TODO: Update once merged + {:opentelemetry_oban, "~> 1.1", + github: "danschultzer/opentelemetry-erlang-contrib", + branch: "oban-v1.27-semantics", + sparse: "instrumentation/opentelemetry_oban"}, + {:opentelemetry_ecto, "~> 1.2"}, + {:hackney, "~> 1.18"}, + {:oban, "~> 2.10"} ] end diff --git a/test_integrations/phoenix_app/mix.lock b/test_integrations/phoenix_app/mix.lock index 11d417cf..982392e2 100644 --- a/test_integrations/phoenix_app/mix.lock +++ b/test_integrations/phoenix_app/mix.lock @@ -3,6 +3,7 @@ "bandit": {:hex, :bandit, "1.6.1", "9e01b93d72ddc21d8c576a704949e86ee6cde7d11270a1d3073787876527a48f", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5a904bf010ea24b67979835e0507688e31ac873d4ffc8ed0e5413e8d77455031"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, @@ -12,8 +13,13 @@ "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "ecto": {:hex, :ecto, "3.12.6", "8bf762dc5b87d85b7aca7ad5fe31ef8142a84cea473a3381eb933bd925751300", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c0cba01795463eebbcd9e4b5ef53c1ee8e68b9c482baef2a80de5a61e7a57fe"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.19.0", "00030bbaba150369ff3754bbc0d2c28858e8f528ae406bf6997d1772d3a03203", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "297b16750fe229f3056fe32afd3247de308094e8b0298aef0d73a8493ce97c81"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "exqlite": {:hex, :exqlite, "0.31.0", "bdf87c618861147382cee29eb8bd91d8cfb0949f89238b353d24fa331527a33a", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "df352de99ba4ce1bac2ad4943d09dbe9ad59e0e7ace55917b493ae289c78fc75"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, @@ -21,6 +27,7 @@ "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, @@ -32,12 +39,21 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, + "opentelemetry_bandit": {:hex, :opentelemetry_bandit, "0.2.0", "60ee4789994d4532ec1b4c05cb8fad333c60ba2c248eb908918369fde045bbda", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.15.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57e31355a860250c9203ae34f0bf0290a14b72ab02b154535e1b2512a0767bca"}, + "opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.2.0", "2382cb47ddc231f953d3b8263ed029d87fbf217915a1da82f49159d122b64865", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "70dfa2e79932e86f209df00e36c980b17a32f82d175f0068bf7ef9a96cf080cf"}, "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.8.0", "5d546123230771ef4174e37bedfd77e3374913304cd6ea3ca82a2add49cd5d56", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "a1f9f271f8d3b02b81462a6bfef7075fd8457fdb06adff5d2537df5e2264d9af"}, + "opentelemetry_oban": {:git, "https://github.com/danschultzer/opentelemetry-erlang-contrib.git", "fda7ab9acde6d845393f8bb4a9876ebb98aedd75", [branch: "oban-v1.27-semantics", sparse: "instrumentation/opentelemetry_oban"]}, + "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"}, + "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"}, "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, + "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, + "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, diff --git a/test_integrations/phoenix_app/priv/repo/migrations/20240926155911_create_users.exs b/test_integrations/phoenix_app/priv/repo/migrations/20240926155911_create_users.exs new file mode 100644 index 00000000..21f4a335 --- /dev/null +++ b/test_integrations/phoenix_app/priv/repo/migrations/20240926155911_create_users.exs @@ -0,0 +1,12 @@ +defmodule PhoenixApp.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users) do + add :name, :string + add :age, :integer + + timestamps(type: :utc_datetime) + end + end +end diff --git a/test_integrations/phoenix_app/priv/repo/migrations/20241213222834_add_oban.exs b/test_integrations/phoenix_app/priv/repo/migrations/20241213222834_add_oban.exs new file mode 100644 index 00000000..f7aa7789 --- /dev/null +++ b/test_integrations/phoenix_app/priv/repo/migrations/20241213222834_add_oban.exs @@ -0,0 +1,11 @@ +defmodule PhoenixApp.Repo.Migrations.AddOban do + use Ecto.Migration + + def up do + Oban.Migration.up() + end + + def down do + Oban.Migration.down() + end +end diff --git a/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs b/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs new file mode 100644 index 00000000..0377ed29 --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs @@ -0,0 +1,45 @@ +defmodule Sentry.Integrations.Phoenix.ObanTest do + use PhoenixAppWeb.ConnCase, async: false + use Oban.Testing, repo: PhoenixApp.Repo + + import Sentry.TestHelpers + + setup do + put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + :ok + end + + defmodule TestWorker do + use Oban.Worker + + @impl Oban.Worker + def perform(_args) do + :timer.sleep(100) + end + end + + test "captures Oban worker execution as transaction" do + :ok = perform_job(TestWorker, %{test: "args"}) + + transactions = Sentry.Test.pop_sentry_transactions() + assert length(transactions) == 1 + + [transaction] = transactions + + assert transaction.transaction == "Sentry.Integrations.Phoenix.ObanTest.TestWorker" + assert transaction.transaction_info == %{source: :custom} + + trace = transaction.contexts.trace + assert trace.origin == "opentelemetry_oban" + assert trace.op == "queue.process" + assert trace.description == "Sentry.Integrations.Phoenix.ObanTest.TestWorker" + assert trace.data["oban.job.job_id"] + assert trace.data["messaging.destination"] == "default" + assert trace.data["oban.job.attempt"] == 1 + + assert [] = transaction.spans + end +end diff --git a/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs b/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs new file mode 100644 index 00000000..095fbbe0 --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs @@ -0,0 +1,28 @@ +defmodule PhoenixApp.RepoTest do + use PhoenixApp.DataCase, async: false + + alias PhoenixApp.{Repo, Accounts.User} + + import Sentry.TestHelpers + + setup do + put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + end + + test "instrumented top-level ecto transaction span" do + Repo.all(User) |> Enum.map(& &1.id) + + transactions = Sentry.Test.pop_sentry_transactions() + + assert length(transactions) == 1 + + assert [transaction] = transactions + + assert transaction.transaction_info == %{source: :custom} + assert transaction.contexts.trace.op == "db" + assert String.starts_with?(transaction.contexts.trace.description, "SELECT") + assert transaction.contexts.trace.data["db.system"] == :sqlite + end +end diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs index b1e81b86..7f597c73 100644 --- a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs @@ -4,21 +4,12 @@ defmodule Sentry.Integrations.Phoenix.ExceptionTest do import Sentry.TestHelpers setup do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") - %{bypass: bypass} - end + put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) - test "GET /exception sends exception to Sentry", %{conn: conn, bypass: bypass} do - Bypass.expect(bypass, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - assert body =~ "RuntimeError" - assert body =~ "Test exception" - assert conn.request_path == "/api/1/envelope/" - assert conn.method == "POST" - Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) - end) + Sentry.Test.start_collecting_sentry_reports() + end + test "GET /exception sends exception to Sentry", %{conn: conn} do assert_raise RuntimeError, "Test exception", fn -> get(conn, ~p"/exception") end diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs new file mode 100644 index 00000000..2a821d4e --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs @@ -0,0 +1,64 @@ +defmodule Sentry.Integrations.Phoenix.TransactionTest do + use PhoenixAppWeb.ConnCase, async: false + + import Sentry.TestHelpers + + setup do + put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + end + + test "GET /transaction", %{conn: conn} do + # TODO: Wrap this in a transaction that the web server usually + # would wrap it in. + get(conn, ~p"/transaction") + + transactions = Sentry.Test.pop_sentry_transactions() + + assert length(transactions) == 1 + + assert [transaction] = transactions + + assert transaction.transaction == "test_span" + assert transaction.transaction_info == %{source: :custom} + + trace = transaction.contexts.trace + assert trace.origin == "phoenix_app" + assert trace.op == "test_span" + assert trace.data == %{} + end + + test "GET /users", %{conn: conn} do + get(conn, ~p"/users") + + transactions = Sentry.Test.pop_sentry_transactions() + + assert length(transactions) == 2 + + assert [mount_transaction, handle_params_transaction] = transactions + + assert mount_transaction.transaction == "PhoenixAppWeb.UserLive.Index.mount" + assert mount_transaction.transaction_info == %{source: :custom} + + trace = mount_transaction.contexts.trace + assert trace.origin == "opentelemetry_phoenix" + assert trace.op == "PhoenixAppWeb.UserLive.Index.mount" + assert trace.data == %{} + + assert [span_ecto] = mount_transaction.spans + + assert span_ecto.op == "db" + assert span_ecto.description == "SELECT u0.\"id\", u0.\"name\", u0.\"age\", u0.\"inserted_at\", u0.\"updated_at\" FROM \"users\" AS u0" + + assert handle_params_transaction.transaction == + "PhoenixAppWeb.UserLive.Index.handle_params" + + assert handle_params_transaction.transaction_info == %{source: :custom} + + trace = handle_params_transaction.contexts.trace + assert trace.origin == "opentelemetry_phoenix" + assert trace.op == "PhoenixAppWeb.UserLive.Index.handle_params" + assert trace.data == %{} + end +end diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs new file mode 100644 index 00000000..46a45142 --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs @@ -0,0 +1,140 @@ +defmodule PhoenixAppWeb.UserLiveTest do + use PhoenixAppWeb.ConnCase, async: false + + import Sentry.TestHelpers + import Phoenix.LiveViewTest + import PhoenixApp.AccountsFixtures + + @create_attrs %{name: "some name", age: 42} + @update_attrs %{name: "some updated name", age: 43} + @invalid_attrs %{name: nil, age: nil} + + setup do + put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + end + + defp create_user(_) do + user = user_fixture() + %{user: user} + end + + describe "Index" do + setup [:create_user] + + test "lists all users", %{conn: conn, user: user} do + {:ok, _index_live, html} = live(conn, ~p"/users") + + assert html =~ "Listing Users" + assert html =~ user.name + end + + test "saves new user", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/users") + + assert index_live |> element("a", "New User") |> render_click() =~ + "New User" + + assert_patch(index_live, ~p"/users/new") + + assert index_live + |> form("#user-form", user: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#user-form", user: @create_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/users") + + html = render(index_live) + assert html =~ "User created successfully" + assert html =~ "some name" + + transactions = Sentry.Test.pop_sentry_transactions() + + transaction_save = + Enum.find(transactions, fn transaction -> + transaction.transaction == "PhoenixAppWeb.UserLive.Index.handle_event#save" + end) + + assert transaction_save.transaction == "PhoenixAppWeb.UserLive.Index.handle_event#save" + assert transaction_save.transaction_info.source == :custom + assert transaction_save.contexts.trace.op == "PhoenixAppWeb.UserLive.Index.handle_event#save" + assert transaction_save.contexts.trace.origin == "opentelemetry_phoenix" + + assert length(transaction_save.spans) == 1 + assert [span] = transaction_save.spans + assert span.op == "db" + assert span.description =~ "INSERT INTO \"users\"" + assert span.data["db.system"] == :sqlite + assert span.data["db.type"] == :sql + assert span.origin == "opentelemetry_ecto" + end + + test "updates user in listing", %{conn: conn, user: user} do + {:ok, index_live, _html} = live(conn, ~p"/users") + + assert index_live |> element("#users-#{user.id} a", "Edit") |> render_click() =~ + "Edit User" + + assert_patch(index_live, ~p"/users/#{user}/edit") + + assert index_live + |> form("#user-form", user: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#user-form", user: @update_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/users") + + html = render(index_live) + assert html =~ "User updated successfully" + assert html =~ "some updated name" + end + + test "deletes user in listing", %{conn: conn, user: user} do + {:ok, index_live, _html} = live(conn, ~p"/users") + + assert index_live |> element("#users-#{user.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#users-#{user.id}") + end + end + + describe "Show" do + setup [:create_user] + + test "displays user", %{conn: conn, user: user} do + {:ok, _show_live, html} = live(conn, ~p"/users/#{user}") + + assert html =~ "Show User" + assert html =~ user.name + end + + test "updates user within modal", %{conn: conn, user: user} do + {:ok, show_live, _html} = live(conn, ~p"/users/#{user}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit User" + + assert_patch(show_live, ~p"/users/#{user}/show/edit") + + assert show_live + |> form("#user-form", user: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert show_live + |> form("#user-form", user: @update_attrs) + |> render_submit() + + assert_patch(show_live, ~p"/users/#{user}") + + html = render(show_live) + assert html =~ "User updated successfully" + assert html =~ "some updated name" + end + end +end diff --git a/test_integrations/phoenix_app/test/support/data_case.ex b/test_integrations/phoenix_app/test/support/data_case.ex index 648de1de..d58f0fe0 100644 --- a/test_integrations/phoenix_app/test/support/data_case.ex +++ b/test_integrations/phoenix_app/test/support/data_case.ex @@ -20,9 +20,9 @@ defmodule PhoenixApp.DataCase do quote do alias PhoenixApp.Repo - # import Ecto - # import Ecto.Changeset - # import Ecto.Query + import Ecto + import Ecto.Changeset + import Ecto.Query import PhoenixApp.DataCase end end @@ -35,9 +35,9 @@ defmodule PhoenixApp.DataCase do @doc """ Sets up the sandbox based on the test tags. """ - def setup_sandbox(_tags) do - # pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PhoenixApp.Repo, shared: not tags[:async]) - # on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PhoenixApp.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) end @doc """ @@ -48,11 +48,11 @@ defmodule PhoenixApp.DataCase do assert %{password: ["password is too short"]} = errors_on(changeset) """ - # def errors_on(changeset) do - # Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> - # Regex.replace(~r"%{(\w+)}", message, fn _, key -> - # opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - # end) - # end) - # end + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end end diff --git a/test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex b/test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 00000000..eb0799e2 --- /dev/null +++ b/test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,21 @@ +defmodule PhoenixApp.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `PhoenixApp.Accounts` context. + """ + + @doc """ + Generate a user. + """ + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> Enum.into(%{ + age: 42, + name: "some name" + }) + |> PhoenixApp.Accounts.create_user() + + user + end +end diff --git a/test_integrations/phoenix_app/test/test_helper.exs b/test_integrations/phoenix_app/test/test_helper.exs index 97b7531c..8b917f93 100644 --- a/test_integrations/phoenix_app/test/test_helper.exs +++ b/test_integrations/phoenix_app/test/test_helper.exs @@ -1,2 +1,2 @@ ExUnit.start() -# Ecto.Adapters.SQL.Sandbox.mode(PhoenixApp.Repo, :manual) +Ecto.Adapters.SQL.Sandbox.mode(PhoenixApp.Repo, :manual) From aedc3263ef6d32cbde1b3ef5adf35818d3559765 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 17 Jun 2025 11:02:22 +0000 Subject: [PATCH 5/5] Update CHANGELOG --- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c84c4d6..be74ff17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,44 @@ ## Unreleased -- Tweak credit card regex handling for OTP-28 ([#898](https://github.com/getsentry/sentry-elixir/pull/898)) +This release comes with a beta support for Traces using OpenTelemetry - please test it out and report any issues you find. + +### New features + +- Beta support for Traces using OpenTelemetry ([#902](https://github.com/getsentry/sentry-elixir/pull/902)) + + To enable Tracing in your Phoenix application, you need to add the following to your `mix.exs`: + + ```elixir + def deps do + [ + # ... + {:sentry, "~> 11.0.0"}, + {:opentelemetry, "~> 1.5"}, + {:opentelemetry_api, "~> 1.4"}, + {:opentelemetry_exporter, "~> 1.0"}, + {:opentelemetry_semantic_conventions, "~> 1.27"}, + {:opentelemetry_phoenix, "~> 2.0"}, + {:opentelemetry_ecto, "~> 1.2"}, + # ... + ] + ``` + + And then configure Tracing in Sentry and OpenTelemetry in your `config.exs`: + + ```elixir + config :sentry, + # ... + traces_sample_rate: 1.0 # any value between 0 and 1.0 enables tracing + + config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} + config :opentelemetry, sampler: {Sentry.OpenTelemetry.Sampler, []} + ``` - Add installer (based on Igniter) ([#876](https://github.com/getsentry/sentry-elixir/pull/876)) +### Various improvements + +- Tweak credit card regex handling for OTP-28 ([#898](https://github.com/getsentry/sentry-elixir/pull/898)) + # Changelog ## 10.10.0