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/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 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..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.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 39221f42..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, @@ -143,6 +150,49 @@ defmodule Sentry.Config do be used as the value for this option. """ ], + traces_sample_rate: [ + type: {:custom, __MODULE__, :__validate_traces_sample_rate__, []}, + 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 not `nil`, 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. + """ + ], + 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.", @@ -607,6 +657,12 @@ defmodule Sentry.Config do @spec sample_rate() :: float() def sample_rate, do: fetch!(:sample_rate) + @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) @@ -644,6 +700,9 @@ defmodule Sentry.Config do @spec integrations() :: keyword() def integrations, do: fetch!(:integrations) + @spec tracing?() :: boolean() + 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 unless key in @valid_keys do @@ -743,6 +802,35 @@ defmodule Sentry.Config do end end + 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 nil or a value between 0.0 and 1.0 (included), got: #{inspect(value)}"} + 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 new file mode 100644 index 00000000..f37de5c3 --- /dev/null +++ b/lib/sentry/opentelemetry/sampler.ex @@ -0,0 +1,188 @@ +if Code.ensure_loaded?(:otel_sampler) do + defmodule Sentry.OpenTelemetry.Sampler do + @moduledoc false + + alias OpenTelemetry.{Span, Tracer} + alias Sentry.ClientReport + alias SamplingContext + + require Logger + + @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, + trace_id, + _links, + span_name, + span_kind, + attributes, + config + ) do + result = + if span_name in config[:drop] do + {:drop, [], []} + else + 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 -> + 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 + + 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 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 + end +end diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex new file mode 100644 index 00000000..af16ac02 --- /dev/null +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -0,0 +1,196 @@ +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 + + :excluded -> + 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/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/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..0cc7a162 100644 --- a/test/sentry/config_test.exs +++ b/test/sentry/config_test.exs @@ -153,6 +153,19 @@ defmodule Sentry.ConfigTest do end 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_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/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 new file mode 100644 index 00000000..3b37c5b0 --- /dev/null +++ b/test/sentry/opentelemetry/sampler_test.exs @@ -0,0 +1,526 @@ +defmodule Sentry.Opentelemetry.SamplerTest do + use Sentry.Case, async: false + + alias Sentry.OpenTelemetry.Sampler + alias Sentry.ClientReport + alias SamplingContext + + 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 + + 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 + + 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 + + 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/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs new file mode 100644 index 00000000..331acd08 --- /dev/null +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -0,0 +1,314 @@ +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 + + 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/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/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 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 = 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/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 207b9cf2..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,9 +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 2055e414..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,9 +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: "../.."} + {:sentry, path: "../.."}, + + {:opentelemetry, "~> 1.5"}, + {:opentelemetry_api, "~> 1.4"}, + {:opentelemetry_exporter, "~> 1.0"}, + {: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 a14316ee..982392e2 100644 --- a/test_integrations/phoenix_app/mix.lock +++ b/test_integrations/phoenix_app/mix.lock @@ -1,21 +1,34 @@ %{ + "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"}, + "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"}, "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"}, + "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"}, "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"}, + "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"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -26,8 +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"}, @@ -46,6 +72,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/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) 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"},