From e08210cd4d18678870f71d2a54a7ddc1a3ade875 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 24 Sep 2024 13:47:24 +0000 Subject: [PATCH 01/39] Support configurable DSN for phoenix test app --- test_integrations/phoenix_app/config/dev.exs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test_integrations/phoenix_app/config/dev.exs b/test_integrations/phoenix_app/config/dev.exs index 9506d05c..f60d5a5b 100644 --- a/test_integrations/phoenix_app/config/dev.exs +++ b/test_integrations/phoenix_app/config/dev.exs @@ -73,3 +73,14 @@ 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 From 2ffd435d37ebc2ce566cddb0dfe28b06edde5b0a Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 4 Sep 2024 15:17:46 +0000 Subject: [PATCH 02/39] Initial work on OTel-based Transactions --- config/config.exs | 2 + lib/sentry.ex | 16 + lib/sentry/application.ex | 1 + lib/sentry/client.ex | 67 ++- lib/sentry/envelope.ex | 24 +- lib/sentry/telemetry/span_processor.ex | 387 ++++++++++++++++++ lib/sentry/test.ex | 96 ++++- lib/sentry/transaction.ex | 56 +++ lib/sentry/transport/sender.ex | 72 +++- lib/sentry/transport/sender_pool.ex | 10 + mix.exs | 4 +- mix.lock | 3 + test/envelope_test.exs | 57 +++ test/sentry/telemetry/span_processor_test.exs | 102 +++++ .../phoenix_app/config/config.exs | 4 + test_integrations/phoenix_app/config/dev.exs | 5 + test_integrations/phoenix_app/config/test.exs | 14 +- test_integrations/phoenix_app/db/dev.sqlite3 | Bin 0 -> 16384 bytes .../phoenix_app/db/dev.sqlite3-shm | Bin 0 -> 32768 bytes .../phoenix_app/db/dev.sqlite3-wal | 0 test_integrations/phoenix_app/db/test.sqlite3 | Bin 0 -> 4096 bytes .../phoenix_app/db/test.sqlite3-shm | Bin 0 -> 32768 bytes .../phoenix_app/db/test.sqlite3-wal | Bin 0 -> 24752 bytes .../lib/phoenix_app/application.ex | 28 +- .../phoenix_app/lib/phoenix_app/repo.ex | 5 + .../phoenix_app/lib/phoenix_app/user.ex | 9 + .../controllers/page_controller.ex | 20 +- .../lib/phoenix_app_web/endpoint.ex | 1 - .../phoenix_app/lib/phoenix_app_web/router.ex | 2 + test_integrations/phoenix_app/mix.exs | 21 +- test_integrations/phoenix_app/mix.lock | 15 + .../20240924163953_create_users.exs | 11 + .../controllers/exception_test.exs | 17 +- .../controllers/transaction_test.exs | 67 +++ 34 files changed, 1080 insertions(+), 36 deletions(-) create mode 100644 lib/sentry/telemetry/span_processor.ex create mode 100644 lib/sentry/transaction.ex create mode 100644 test/sentry/telemetry/span_processor_test.exs create mode 100644 test_integrations/phoenix_app/db/dev.sqlite3 create mode 100644 test_integrations/phoenix_app/db/dev.sqlite3-shm create mode 100644 test_integrations/phoenix_app/db/dev.sqlite3-wal create mode 100644 test_integrations/phoenix_app/db/test.sqlite3 create mode 100644 test_integrations/phoenix_app/db/test.sqlite3-shm create mode 100644 test_integrations/phoenix_app/db/test.sqlite3-wal create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/repo.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/user.ex create mode 100644 test_integrations/phoenix_app/priv/repo/migrations/20240924163953_create_users.exs create mode 100644 test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs diff --git a/config/config.exs b/config/config.exs index 1184ea49..3d97efd5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -15,4 +15,6 @@ if config_env() == :test do config :logger, backends: [] end +config :opentelemetry, span_processor: {Sentry.Telemetry.SpanProcessor, []} + config :phoenix, :json_library, Jason diff --git a/lib/sentry.ex b/lib/sentry.ex index 4dbfd993..929a80c5 100644 --- a/lib/sentry.ex +++ b/lib/sentry.ex @@ -362,6 +362,22 @@ defmodule Sentry do end end + def send_transaction(transaction, opts \\ []) do + # TODO: remove on v11.0.0, :included_environments was deprecated in 10.0.0. + included_envs = Config.included_environments() + + cond do + Config.test_mode?() -> + Client.send_transaction(transaction, opts) + + included_envs == :all or to_string(Config.environment_name()) in included_envs -> + Client.send_transaction(transaction, opts) + + true -> + :ignored + end + end + @doc """ Captures a check-in built with the given `options`. diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index e592ae04..daf6ca8f 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -27,6 +27,7 @@ defmodule Sentry.Application do Sentry.Sources, Sentry.Dedupe, Sentry.ClientReport.Sender, + Sentry.Telemetry.SpanProcessor.SpanStorage, {Sentry.Integrations.CheckInIDMappings, [ max_expected_check_in_time: diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 87c124fc..05dd5f63 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -16,7 +16,8 @@ defmodule Sentry.Client do Interfaces, LoggerUtils, Transport, - Options + Options, + Transaction } require Logger @@ -107,6 +108,29 @@ defmodule Sentry.Client do |> Transport.encode_and_post_envelope(client, request_retries) end + def send_transaction(%Transaction{} = transaction, opts \\ []) do + # opts = validate_options!(opts) + + result_type = Keyword.get_lazy(opts, :result, &Config.send_result/0) + client = Keyword.get_lazy(opts, :client, &Config.client/0) + + request_retries = + Keyword.get_lazy(opts, :request_retries, fn -> + Application.get_env(:sentry, :request_retries, Transport.default_retries()) + end) + + case encode_and_send(transaction, result_type, client, request_retries) do + {:ok, id} -> + {:ok, id} + + {:error, {status, headers, body}} -> + {:error, ClientError.server_error(status, headers, body)} + + {:error, reason} -> + {:error, ClientError.new(reason)} + end + end + defp sample_event(sample_rate) do cond do sample_rate == 1 -> :ok @@ -205,6 +229,42 @@ defmodule Sentry.Client do end end + defp encode_and_send( + %Transaction{} = transaction, + _result_type = :sync, + client, + request_retries + ) do + case Sentry.Test.maybe_collect(transaction) do + :collected -> + {:ok, ""} + + :not_collecting -> + send_result = + transaction + |> Envelope.from_transaction() + |> Transport.encode_and_post_envelope(client, request_retries) + + send_result + end + end + + defp encode_and_send( + %Transaction{} = transaction, + _result_type = :none, + client, + _request_retries + ) do + case Sentry.Test.maybe_collect(transaction) do + :collected -> + {:ok, ""} + + :not_collecting -> + :ok = Transport.Sender.send_async(client, transaction) + {:ok, ""} + end + end + @spec render_event(Event.t()) :: map() def render_event(%Event{} = event) do json_library = Config.json_library() @@ -225,6 +285,11 @@ defmodule Sentry.Client do |> update_if_present(:threads, fn list -> Enum.map(list, &render_thread/1) end) end + @spec render_transaction(%Transaction{}) :: map() + def render_transaction(%Transaction{} = transaction) do + Transaction.to_map(transaction) + end + defp render_exception(%Interfaces.Exception{} = exception) do exception |> Map.from_struct() diff --git a/lib/sentry/envelope.ex b/lib/sentry/envelope.ex index 649f6e85..b29ce9c4 100644 --- a/lib/sentry/envelope.ex +++ b/lib/sentry/envelope.ex @@ -2,7 +2,7 @@ defmodule Sentry.Envelope do @moduledoc false # https://develop.sentry.dev/sdk/envelopes/ - alias Sentry.{Attachment, CheckIn, ClientReport, Config, Event, UUID} + alias Sentry.{Attachment, CheckIn, ClientReport, Config, Event, UUID, Transaction} @type t() :: %__MODULE__{ event_id: UUID.t(), @@ -46,6 +46,17 @@ defmodule Sentry.Envelope do } end + @doc """ + Creates a new envelope containing a transaction with spans. + """ + @spec from_transaction(Sentry.Transaction.t()) :: t() + def from_transaction(%Transaction{} = transaction) do + %__MODULE__{ + event_id: transaction.event_id, + items: [transaction] + } + end + @doc """ Returns the "data category" of the envelope's contents (to be used in client reports and more). """ @@ -126,4 +137,15 @@ defmodule Sentry.Envelope do throw(error) end end + + defp item_to_binary(json_library, %Transaction{} = transaction) do + case transaction |> Sentry.Client.render_transaction() |> json_library.encode() do + {:ok, encoded_transaction} -> + header = ~s({"type": "transaction", "length": #{byte_size(encoded_transaction)}}) + [header, ?\n, encoded_transaction, ?\n] + + {:error, _reason} = error -> + throw(error) + end + end end diff --git a/lib/sentry/telemetry/span_processor.ex b/lib/sentry/telemetry/span_processor.ex new file mode 100644 index 00000000..6155ef81 --- /dev/null +++ b/lib/sentry/telemetry/span_processor.ex @@ -0,0 +1,387 @@ +defmodule Sentry.Telemetry.SpanProcessor do + @behaviour :otel_span_processor + + require Record + + @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") + Record.defrecordp(:span, @fields) + + alias Sentry.{Span, Transaction} + + defmodule SpanStorage do + use GenServer + + def start_link(_opts) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + def init(_) do + {:ok, %{root_spans: %{}, child_spans: %{}}} + end + + def store_span(span_data) do + GenServer.call(__MODULE__, {:store_span, span_data}) + end + + def get_root_span(span_id) do + GenServer.call(__MODULE__, {:get_root_span, span_id}) + end + + def get_child_spans(parent_span_id) do + GenServer.call(__MODULE__, {:get_child_spans, parent_span_id}) + end + + def update_span(span_data) do + GenServer.call(__MODULE__, {:update_span, span_data}) + end + + def handle_call({:store_span, span_data}, _from, state) do + if span_data[:parent_span_id] == :undefined do + new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) + {:reply, :ok, new_state} + else + new_state = + update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> + (spans || []) ++ [span_data] + end) + + {:reply, :ok, new_state} + end + end + + def handle_call({:get_root_span, span_id}, _from, state) do + {:reply, state.root_spans[span_id], state} + end + + def handle_call({:get_child_spans, parent_span_id}, _from, state) do + {:reply, state.child_spans[parent_span_id] || [], state} + end + + def handle_call({:update_span, span_data}, _from, state) do + if span_data[:parent_span_id] == :undefined do + new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) + {:reply, :ok, new_state} + else + new_state = + update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> + Enum.map(spans || [], fn span -> + if span[:span_id] == span_data[:span_id], do: span_data, else: span + end) + end) + + {:reply, :ok, new_state} + end + end + end + + @impl true + def on_start(_ctx, otel_span, _config) do + span_record = span(otel_span) + + SpanStorage.store_span(span_record) + + otel_span + end + + @impl true + def on_end(otel_span, _config) do + span_record = span(otel_span) + + SpanStorage.update_span(span_record) + + if span_record[:parent_span_id] == :undefined do + root_span = SpanStorage.get_root_span(span_record[:span_id]) + child_spans = SpanStorage.get_child_spans(span_record[:span_id]) + + transaction = transaction_from_root_span(root_span, child_spans) + Sentry.send_transaction(transaction) + end + + :ok + end + + @impl true + def force_flush(_config) do + :ok + end + + defp transaction_from_root_span(root_span, child_spans) do + {:attributes, _, _, _, attributes} = root_span[:attributes] + + build_transaction(attributes, root_span, child_spans) + end + + defp build_transaction(attributes, root_span, child_spans) when is_map(attributes) do + trace_id = cast_trace_id(root_span[:trace_id]) + + case root_span[:instrumentation_scope] do + {:instrumentation_scope, origin, _version, _} -> + build_transaction(origin, trace_id, root_span, child_spans, attributes) + + :undefined -> + build_transaction(trace_id, root_span, child_spans) + end + end + + defp build_transaction(trace_id, root_span, child_spans) when is_binary(trace_id) do + Transaction.new(%{ + transaction: root_span[:name], + start_timestamp: cast_timestamp(root_span[:start_time]), + timestamp: cast_timestamp(root_span[:end_time]), + contexts: %{ + trace: %{ + trace_id: trace_id, + span_id: cast_span_id(root_span[:span_id]), + op: root_span[:name] + } + }, + spans: Enum.map([root_span | child_spans], &build_span(&1, trace_id)) + }) + end + + defp build_transaction( + "opentelemetry_ecto" = origin, + trace_id, + root_span, + child_spans, + attributes + ) do + Transaction.new(%{ + transaction: root_span[:name], + start_timestamp: cast_timestamp(root_span[:start_time]), + timestamp: cast_timestamp(root_span[:end_time]), + transaction_info: %{ + source: "db" + }, + contexts: %{ + trace: %{ + trace_id: trace_id, + span_id: cast_span_id(root_span[:span_id]), + parent_span_id: cast_span_id(root_span[:parent_span_id]), + op: "db", + origin: origin + } + }, + platform: "elixir", + sdk: %{ + name: "sentry.elixir", + version: "10.7.1" + }, + data: %{ + "db.system" => attributes[:"db.system"], + "db.name" => attributes[:"db.name"], + "db.instance" => attributes[:"db.instance"], + "db.type" => attributes[:"db.type"], + "db.url" => attributes[:"db.url"], + "total_time_microseconds" => attributes[:total_time_microseconds], + "idle_time_microseconds" => attributes[:idle_time_microseconds], + "decode_time_microseconds" => attributes[:decode_time_microseconds], + "queue_time_microseconds" => attributes[:queue_time_microseconds], + "query_time_microseconds" => attributes[:query_time_microseconds] + }, + measurements: %{}, + spans: Enum.map(child_spans, &build_span(&1, trace_id)) + }) + end + + defp build_transaction( + "opentelemetry_phoenix" = origin, + trace_id, + root_span, + child_spans, + attributes + ) do + name = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" + trace = build_trace_context(trace_id, origin, root_span, attributes) + + Transaction.new(%{ + transaction: name, + start_timestamp: cast_timestamp(root_span[:start_time]), + timestamp: cast_timestamp(root_span[:end_time]), + transaction_info: %{ + source: "view" + }, + contexts: %{ + trace: trace + }, + platform: "elixir", + sdk: %{ + name: "sentry.elixir", + version: "10.7.1" + }, + request: %{ + url: attributes[:"http.target"], + method: attributes[:"http.method"], + headers: %{ + "User-Agent" => attributes[:"http.user_agent"] + }, + env: %{ + "SERVER_NAME" => attributes[:"net.host.name"], + "SERVER_PORT" => attributes[:"net.host.port"] + } + }, + data: %{ + "http.response.status_code" => attributes[:"http.status_code"], + "method" => attributes[:"http.method"], + "path" => attributes[:"http.target"], + "params" => %{ + "controller" => attributes[:"phoenix.plug"], + "action" => attributes[:"phoenix.action"] + } + }, + measurements: %{}, + spans: Enum.map(child_spans, &build_span(&1, trace_id)) + }) + end + + defp build_transaction("opentelemetry_bandit", trace_id, root_span, child_spans, attributes) do + %Sentry.Transaction{ + event_id: Sentry.UUID.uuid4_hex(), + start_timestamp: cast_timestamp(root_span[:start_time]), + timestamp: cast_timestamp(root_span[:end_time]), + transaction: attributes[:"http.target"], + transaction_info: %{ + source: "url" + }, + contexts: %{ + trace: %{ + trace_id: trace_id, + span_id: cast_span_id(root_span[:span_id]), + parent_span_id: cast_span_id(root_span[:parent_span_id]) + } + }, + platform: "elixir", + sdk: %{ + name: "sentry.elixir", + version: "10.7.1" + }, + request: %{ + url: attributes[:"http.url"], + method: attributes[:"http.method"], + headers: %{ + "User-Agent" => attributes[:"http.user_agent"] + }, + env: %{ + "SERVER_NAME" => attributes[:"net.peer.name"], + "SERVER_PORT" => attributes[:"net.peer.port"] + } + }, + measurements: %{}, + spans: Enum.map(child_spans, &build_span(&1, trace_id)) + } + end + + defp build_trace_context(trace_id, origin, root_span, attributes) do + %{ + trace_id: trace_id, + span_id: cast_span_id(root_span[:span_id]), + parent_span_id: nil, + op: "http.server", + origin: origin, + data: %{ + "http.response.status_code" => attributes[:"http.status_code"] + } + } + end + + defp build_span(span_record, trace_id) do + {:attributes, _, _, _, attributes} = span_record[:attributes] + + case span_record[:instrumentation_scope] do + {:instrumentation_scope, origin, _version, _} -> + build_span(origin, span_record, trace_id, attributes) + + :undefined -> + build_span(:custom, span_record, trace_id, attributes) + end + end + + defp build_span("opentelemetry_phoenix" = origin, span_record, trace_id, attributes) do + op = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" + + %Span{ + op: op, + start_timestamp: cast_timestamp(span_record[:start_time]), + timestamp: cast_timestamp(span_record[:end_time]), + trace_id: trace_id, + span_id: cast_span_id(span_record[:span_id]), + parent_span_id: cast_span_id(span_record[:parent_span_id]), + description: attributes[:"http.route"], + origin: origin + } + end + + defp build_span("phoenix_app", span_record, trace_id, _attributes) do + %Span{ + trace_id: trace_id, + op: span_record[:name], + start_timestamp: cast_timestamp(span_record[:start_time]), + timestamp: cast_timestamp(span_record[:end_time]), + span_id: cast_span_id(span_record[:span_id]), + parent_span_id: cast_span_id(span_record[:parent_span_id]) + } + end + + defp build_span("opentelemetry_bandit" = origin, span_record, trace_id, _attributes) do + %Span{ + trace_id: trace_id, + op: span_record[:name], + start_timestamp: cast_timestamp(span_record[:start_time]), + timestamp: cast_timestamp(span_record[:end_time]), + span_id: cast_span_id(span_record[:span_id]), + parent_span_id: cast_span_id(span_record[:parent_span_id]), + description: span_record[:name], + origin: origin + } + end + + defp build_span("opentelemetry_ecto" = origin, span_record, trace_id, attributes) do + %Span{ + trace_id: trace_id, + op: span_record[:name], + start_timestamp: cast_timestamp(span_record[:start_time]), + timestamp: cast_timestamp(span_record[:end_time]), + span_id: cast_span_id(span_record[:span_id]), + parent_span_id: cast_span_id(span_record[:parent_span_id]), + origin: origin, + data: %{ + "db.system" => attributes[:"db.system"], + "db.name" => attributes[:"db.name"] + } + } + end + + defp build_span(:custom, span_record, trace_id, _attributes) do + %Span{ + trace_id: trace_id, + op: span_record[:name], + start_timestamp: cast_timestamp(span_record[:start_time]), + timestamp: cast_timestamp(span_record[:end_time]), + span_id: cast_span_id(span_record[:span_id]), + parent_span_id: cast_span_id(span_record[:parent_span_id]) + } + 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 diff --git a/lib/sentry/test.ex b/lib/sentry/test.ex index 42c77ca3..41d71258 100644 --- a/lib/sentry/test.ex +++ b/lib/sentry/test.ex @@ -78,6 +78,7 @@ defmodule Sentry.Test do @server __MODULE__.OwnershipServer @key :events + @transaction_key :transactions # Used internally when reporting an event, *before* reporting the actual event. @doc false @@ -115,6 +116,48 @@ defmodule Sentry.Test do end end + # Used internally when reporting a transaction, *before* reporting the actual transaction. + @doc false + @spec maybe_collect(Sentry.Transaction.t()) :: :collected | :not_collecting + def maybe_collect(%Sentry.Transaction{} = transaction) do + if Sentry.Config.test_mode?() do + dsn_set? = not is_nil(Sentry.Config.dsn()) + ensure_ownership_server_started() + + case NimbleOwnership.fetch_owner(@server, callers(), @transaction_key) do + {:ok, owner_pid} -> + result = + NimbleOwnership.get_and_update( + @server, + owner_pid, + @transaction_key, + fn transactions -> + {:collected, (transactions || []) ++ [transaction]} + end + ) + + case result do + {:ok, :collected} -> + :collected + + {:error, error} -> + raise ArgumentError, + "cannot collect Sentry transactions: #{Exception.message(error)}" + end + + :error when dsn_set? -> + :not_collecting + + # If the :dsn option is not set and we didn't capture the transaction, it's alright, + # we can just swallow it. + :error -> + :collected + end + else + :not_collecting + end + end + @doc """ Starts collecting events from the current process. @@ -135,7 +178,8 @@ defmodule Sentry.Test do @doc since: "10.2.0" @spec start_collecting_sentry_reports(map()) :: :ok def start_collecting_sentry_reports(_context \\ %{}) do - start_collecting() + start_collecting(key: @key) + start_collecting(key: @transaction_key) end @doc """ @@ -177,6 +221,7 @@ defmodule Sentry.Test do @doc since: "10.2.0" @spec start_collecting(keyword()) :: :ok def start_collecting(options \\ []) when is_list(options) do + key = Keyword.get(options, :key, @key) owner_pid = Keyword.get(options, :owner, self()) cleanup? = Keyword.get(options, :cleanup, true) @@ -190,7 +235,7 @@ defmodule Sentry.Test do # Make sure the ownership server is started (this is idempotent). ensure_ownership_server_started() - case NimbleOwnership.fetch_owner(@server, callers, @key) do + case NimbleOwnership.fetch_owner(@server, callers, key) do # No-op {tag, ^owner_pid} when tag in [:ok, :shared_owner] -> :ok @@ -207,7 +252,7 @@ defmodule Sentry.Test do end {:ok, _} = - NimbleOwnership.get_and_update(@server, self(), @key, fn events -> + NimbleOwnership.get_and_update(@server, self(), key, fn events -> {:ignored, events || []} end) @@ -302,6 +347,51 @@ defmodule Sentry.Test do end end + @doc """ + Pops all the collected transactions from the current process. + + This function returns a list of all the transactions that have been collected from the current + process and all the processes that were allowed through it. If the current process + is not collecting transactions, this function raises an error. + + After this function returns, the current process will still be collecting transactions, but + the collected transactions will be reset to `[]`. + + ## Examples + + iex> Sentry.Test.start_collecting_sentry_reports() + :ok + iex> Sentry.send_transaction(%Sentry.Transaction{}) + {:ok, ""} + iex> [%Sentry.Transaction{}] = Sentry.Test.pop_sentry_transactions() + + """ + @doc since: "10.2.0" + @spec pop_sentry_transactions(pid()) :: [Sentry.Transaction.t()] + def pop_sentry_transactions(owner_pid \\ self()) when is_pid(owner_pid) do + result = + try do + NimbleOwnership.get_and_update(@server, owner_pid, @transaction_key, fn + nil -> {:not_collecting, []} + transactions when is_list(transactions) -> {transactions, []} + end) + catch + :exit, {:noproc, _} -> + raise ArgumentError, "not collecting reported transactions from #{inspect(owner_pid)}" + end + + case result do + {:ok, :not_collecting} -> + raise ArgumentError, "not collecting reported transactions from #{inspect(owner_pid)}" + + {:ok, transactions} -> + transactions + + {:error, error} when is_exception(error) -> + raise ArgumentError, "cannot pop Sentry transactions: #{Exception.message(error)}" + end + end + ## Helpers defp ensure_ownership_server_started do diff --git a/lib/sentry/transaction.ex b/lib/sentry/transaction.ex new file mode 100644 index 00000000..16265873 --- /dev/null +++ b/lib/sentry/transaction.ex @@ -0,0 +1,56 @@ +defmodule Sentry.Transaction do + @type t() :: %__MODULE__{} + + alias Sentry.{UUID} + + defstruct [ + :event_id, + :start_timestamp, + :timestamp, + :transaction, + :transaction_info, + :contexts, + :platform, + :sdk, + :request, + :measurements, + spans: [], + type: "transaction" + ] + + def new(attrs) do + struct(__MODULE__, Map.put(attrs, :event_id, UUID.uuid4_hex())) + end + + # Used to then encode the returned map to JSON. + @doc false + def to_map(%__MODULE__{} = transaction) do + Map.put( + Map.from_struct(transaction), + :spans, + Enum.map(transaction.spans, &Sentry.Span.to_map(&1)) + ) + end +end + +defmodule Sentry.Span do + defstruct [ + :op, + :start_timestamp, + :timestamp, + :description, + :span_id, + :parent_span_id, + :trace_id, + :tags, + :data, + :origin, + :status + ] + + # Used to then encode the returned map to JSON. + @doc false + def to_map(%__MODULE__{} = span) do + Map.from_struct(span) + end +end diff --git a/lib/sentry/transport/sender.ex b/lib/sentry/transport/sender.ex index 1f7df19a..923a7a63 100644 --- a/lib/sentry/transport/sender.ex +++ b/lib/sentry/transport/sender.ex @@ -3,7 +3,7 @@ defmodule Sentry.Transport.Sender do use GenServer - alias Sentry.{Envelope, Event, Transport} + alias Sentry.{Envelope, Event, Transport, Transaction, LoggerUtils} require Logger @@ -24,6 +24,13 @@ defmodule Sentry.Transport.Sender do GenServer.cast({:via, Registry, {@registry, random_index}}, {:send, client, event}) end + @spec send_async(module(), Transaction.t()) :: :ok + def send_async(client, %Transaction{} = transaction) when is_atom(client) do + random_index = Enum.random(1..Transport.SenderPool.pool_size()) + Transport.SenderPool.increase_queued_transactions_counter() + GenServer.cast({:via, Registry, {@registry, random_index}}, {:send, client, transaction}) + end + ## State defstruct [] @@ -51,4 +58,67 @@ defmodule Sentry.Transport.Sender do {:noreply, state} end + + @impl GenServer + def handle_cast({:send, client, %Transaction{} = transaction}, %__MODULE__{} = state) do + envelope = Envelope.from_transaction(transaction) + + envelope + |> Transport.encode_and_post_envelope(client) + |> maybe_log_send_result([transaction]) + + # We sent an event, so we can decrease the number of queued events. + Transport.SenderPool.decrease_queued_events_counter() + + {:noreply, state} + end + + ## Helpers + + defp maybe_log_send_result(send_result, events) do + if Enum.any?(events, fn item -> + case item do + %Event{} -> item.source == :logger + _ -> false + end + end) do + :ok + else + message = + case send_result do + {:error, {:invalid_json, error}} -> + "Unable to encode JSON Sentry error - #{inspect(error)}" + + {:error, {:request_failure, last_error}} -> + case last_error do + {kind, data, stacktrace} + when kind in [:exit, :throw, :error] and is_list(stacktrace) -> + Exception.format(kind, data, stacktrace) + + _other -> + "Error in HTTP Request to Sentry - #{inspect(last_error)}" + end + + {:error, http_reponse} -> + {status, headers, _body} = http_reponse + + error_header = + :proplists.get_value("X-Sentry-Error", headers, nil) || + :proplists.get_value("x-sentry-error", headers, nil) || "" + + if error_header != "" do + "Received #{status} from Sentry server: #{error_header}" + else + "Received #{status} from Sentry server" + end + + result -> + result + end + + if message do + LoggerUtils.log(fn -> ["Failed to send Sentry event. ", message] end) + end + end + end end diff --git a/lib/sentry/transport/sender_pool.ex b/lib/sentry/transport/sender_pool.ex index 1f038486..b247441a 100644 --- a/lib/sentry/transport/sender_pool.ex +++ b/lib/sentry/transport/sender_pool.ex @@ -4,6 +4,7 @@ defmodule Sentry.Transport.SenderPool do use Supervisor @queued_events_key {__MODULE__, :queued_events} + @queued_transactions_key {__MODULE__, :queued_transactions} @spec start_link(keyword()) :: Supervisor.on_start() def start_link([] = _opts) do @@ -15,6 +16,9 @@ defmodule Sentry.Transport.SenderPool do queued_events_counter = :counters.new(1, []) :persistent_term.put(@queued_events_key, queued_events_counter) + queued_transactions_counter = :counters.new(1, []) + :persistent_term.put(@queued_transactions_key, queued_transactions_counter) + children = for index <- 1..pool_size() do Supervisor.child_spec({Sentry.Transport.Sender, index: index}, @@ -42,6 +46,12 @@ defmodule Sentry.Transport.SenderPool do :counters.add(counter, 1, 1) end + @spec increase_queued_transactions_counter() :: :ok + def increase_queued_transactions_counter do + counter = :persistent_term.get(@queued_transactions_key) + :counters.add(counter, 1, 1) + end + @spec decrease_queued_events_counter() :: :ok def decrease_queued_events_counter do counter = :persistent_term.get(@queued_events_key) diff --git a/mix.exs b/mix.exs index acfb35fa..b107fda8 100644 --- a/mix.exs +++ b/mix.exs @@ -111,7 +111,9 @@ 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.4"}, + {:opentelemetry_api, "~> 1.3"} ] end diff --git a/mix.lock b/mix.lock index 4d7db4c2..eb742f2e 100644 --- a/mix.lock +++ b/mix.lock @@ -32,6 +32,9 @@ "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "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.4.0", "f928923ed80adb5eb7894bac22e9a198478e6a8f04020ae1d6f289fdcad0b498", [:rebar3], [{:opentelemetry_api, "~> 1.3.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "50b32ce127413e5d87b092b4d210a3449ea80cd8224090fe68d73d576a3faa15"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.3.1", "83b4713593f80562d9643c4ab0b6f80f3c5fa4c6d0632c43e11b2ccb6b04dfa7", [:mix, :rebar3], [{:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "9e8a5cc38671e3ac61be48abe5f6b3afdbbb50a1dc08b7950c56f169611505c1"}, + "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "0.2.0", "b67fe459c2938fcab341cb0951c44860c62347c005ace1b50f8402576f241435", [:mix, :rebar3], [], "hexpm", "d61fa1f5639ee8668d74b527e6806e0503efc55a42db7b5f39939d84c07d6895"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, diff --git a/test/envelope_test.exs b/test/envelope_test.exs index c6b5e663..35275957 100644 --- a/test/envelope_test.exs +++ b/test/envelope_test.exs @@ -113,6 +113,63 @@ defmodule Sentry.EnvelopeTest do assert decoded_check_in["monitor_slug"] == "test" assert decoded_check_in["status"] == "ok" end + + test "works with transactions" do + put_test_config(environment_name: "test") + + spans = [ + %Sentry.Span{ + start_timestamp: 1_588_601_261.481_961, + timestamp: 1_588_601_261.488_901, + description: "GET /sockjs-node/info", + op: "http", + span_id: "b01b9f6349558cd1", + parent_span_id: "b0e6f15b45c36b12", + trace_id: "1e57b752bc6e4544bbaa246cd1d05dee", + tags: %{"http.status_code" => "200"}, + data: %{ + "url" => "http://localhost:8080/sockjs-node/info?t=1588601703755", + "status_code" => 200, + "type" => "xhr", + "method" => "GET" + } + }, + %Sentry.Span{ + start_timestamp: 1_588_601_261.535_386, + timestamp: 1_588_601_261.544_196, + description: "Vue ", + op: "update", + span_id: "b980d4dec78d7344", + parent_span_id: "9312d0d18bf51736", + trace_id: "1e57b752bc6e4544bbaa246cd1d05dee" + } + ] + + transaction = %Sentry.Transaction{ + start_timestamp: System.system_time(:second), + timestamp: System.system_time(:second), + spans: spans + } + + envelope = Envelope.from_transaction(transaction) + + assert {:ok, encoded} = Envelope.to_binary(envelope) + + assert [_id_line, _header_line, transaction_line] = String.split(encoded, "\n", trim: true) + + assert {:ok, decoded_transaction} = Jason.decode(transaction_line) + assert decoded_transaction["type"] == "transaction" + assert decoded_transaction["start_timestamp"] == transaction.start_timestamp + assert decoded_transaction["timestamp"] == transaction.timestamp + + assert [span1, span2] = decoded_transaction["spans"] + + assert span1["start_timestamp"] == List.first(spans).start_timestamp + assert span1["timestamp"] == List.first(spans).timestamp + + assert span2["start_timestamp"] == List.last(spans).start_timestamp + assert span2["timestamp"] == List.last(spans).timestamp + end end test "works with client reports" do diff --git a/test/sentry/telemetry/span_processor_test.exs b/test/sentry/telemetry/span_processor_test.exs new file mode 100644 index 00000000..ea28bcc5 --- /dev/null +++ b/test/sentry/telemetry/span_processor_test.exs @@ -0,0 +1,102 @@ +defmodule Sentry.Telemetry.SpanProcessorTest do + use Sentry.Case, async: false + + import Sentry.TestHelpers + + defmodule TestEndpoint do + require OpenTelemetry.Tracer, as: Tracer + + def instrumented_function do + Tracer.with_span "instrumented_function" do + :timer.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 + :timer.sleep(140) + end + end + end + + test "sends captured root spans as transactions" do + put_test_config(environment_name: "test") + + Sentry.Test.start_collecting_sentry_reports() + + TestEndpoint.child_instrumented_function("one") + + 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) == 1 + + assert_valid_trace_id(transaction.contexts.trace.trace_id) + + assert [span] = transaction.spans + + assert span.op == "child_instrumented_function_one" + end + + test "sends captured spans as transactions with child spans" do + put_test_config(environment_name: "test") + + 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) == 3 + + [root_span, 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 root_span.timestamp >= child_span_one.timestamp + assert root_span.timestamp >= child_span_two.timestamp + assert root_span.start_timestamp <= child_span_one.start_timestamp + assert root_span.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 + + 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 String.length(trace_id) == 32, "Expected trace_id to be 32 characters long #{trace_id}" + + assert String.match?(trace_id, ~r/^[a-f0-9]{32}$/), + "Expected trace_id to be a lowercase hex string" + end +end diff --git a/test_integrations/phoenix_app/config/config.exs b/test_integrations/phoenix_app/config/config.exs index 1f7d4a0d..32f7435e 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 @@ -60,6 +61,9 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :opentelemetry, + span_processor: {Sentry.Telemetry.SpanProcessor, []} + # 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 f60d5a5b..5fb8095a 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. # diff --git a/test_integrations/phoenix_app/config/test.exs b/test_integrations/phoenix_app/config/test.exs index 207b9cf2..4eab4114 100644 --- a/test_integrations/phoenix_app/config/test.exs +++ b/test_integrations/phoenix_app/config/test.exs @@ -1,5 +1,10 @@ import Config +# Configure your database +config :phoenix_app, PhoenixApp.Repo, + adapter: Ecto.Adapters.SQLite3, + 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 +29,8 @@ 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, + test_mode: true diff --git a/test_integrations/phoenix_app/db/dev.sqlite3 b/test_integrations/phoenix_app/db/dev.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..1408c12ffee85d8a4d4f9304fc080e58cc549180 GIT binary patch literal 16384 zcmeI%Pfvni90%|RaCu7G+G6YUjFV1HLu$)Y)L3&N8sU~FM@q#e7=j(V*}C~gdNaL* zw$=-2J!l>z*S-2a1D-v9d43;Gzj1k?yOBe0h9l38Xq9Xco+rnY5<+-(r`WwL$u_qf z_@U;>-Xz#$6NJo+WG9(dR-r%u0uX=z1Rwwb2tWV=5P-n{7HA0@VxhowBKvyagu#I2 zrQOg8#*W`hHdCjjrdk@c)DvB!iMcu7_M9SHnV;_RX;Bop$L;x^F^Bd6cBqeUMz znie(My3Pj2_kDZqSbi#SX|Y`9rU#3Fdv}g!cRlxZWJm7M55r^!zk29z<8c5FEJC(; zZ4~Q{nvZjHF8fZ{g#rNxKmY;|fB*y_009U<00Izzz^@h9d3u>WzkLJ(smc{uDOHY2 zvSRHY))b{CSK|Di{q#qH00bZa0SG_<0uX=z1Rwwb2teQu3n2gh!ygx+K>z{}fB*y_ R009U<00Izz00h1SJ^|Lpi>Ckp literal 0 HcmV?d00001 diff --git a/test_integrations/phoenix_app/db/dev.sqlite3-shm b/test_integrations/phoenix_app/db/dev.sqlite3-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r37U}9o$P*7lCU|@t|AVoG{WYDWBLM0&p literal 0 HcmV?d00001 diff --git a/test_integrations/phoenix_app/db/test.sqlite3-wal b/test_integrations/phoenix_app/db/test.sqlite3-wal new file mode 100644 index 0000000000000000000000000000000000000000..0793dd61235d2ee9761a6779fd4339b419349116 GIT binary patch literal 24752 zcmeI)!E4h{90%~1q;v>w6U0Tu!y_uJbak_96@`MG1gUoEtQlGmN?e~BXgk}a3cE;$ z2W8;jFgy(p11}y#7>XVz2;xo99{dYr2M_9d=~_}(q(@7>k2G!G>o09S=_T*?OV2(G zuaa2nFd+wsX!7gdi4RXx&-Z=bI#k|&@-gp{{Qci{wsJpO&x~tVv$o6V?d5vKa_KCO zmSu8*Qn8EOhX@l*#D~0B?UlDb-*&&3GC8$U8|5R?_zK|*8U!E!0SG_<0uX=z1Rwwb z2tXiU0!>94QKzS+X4188m6@}2msPA{#lBOwTzk3d?CFp%=Jm9xQ!_o6)v2~;T%(iP zeO7mPutqb6sbA9bbRnOaPv;Btie8wanq75R-DRbs+7+z6={L{!uTWa2
rXKJ@J~?C(`Y9 zsc)v8rWegz#^6Eox?%FXyZD55!o6P-o*Frm8jIO1A8gz!S^fllcP#Q>)eCg*FL?Df z`eyU^=z6=xzeUDd!TwR==pg_B2tWV=5P$##AOHafKmY>6EimzDdu`+EubM(;<1_I@ zZ01}no-j|JIiF1Mg&&*9|99sFjuGSf#QLWPxQ}4C&pN(61Rwwb2tWV=5P$##AOHaf yKwv-taSuSK^Zx=TlAlxT-R5P?3k;}-w?O~`5P$##AOHafKmY;|fB*!BSl|yAHTw(z literal 0 HcmV?d00001 diff --git a/test_integrations/phoenix_app/lib/phoenix_app/application.ex b/test_integrations/phoenix_app/lib/phoenix_app/application.ex index b97f81ba..e6a90df9 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app/application.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app/application.ex @@ -7,8 +7,21 @@ 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() + OpentelemetryEcto.setup([:phoenix_app, :repo]) + 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 @@ -25,12 +38,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/user.ex b/test_integrations/phoenix_app/lib/phoenix_app/user.ex new file mode 100644 index 00000000..728d982d --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app/user.ex @@ -0,0 +1,9 @@ +defmodule PhoenixApp.User do + use Ecto.Schema + + schema "users" do + field :name, :string + + timestamps() + 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..177fd1f6 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/router.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex index 409aeb27..6bb9caad 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,8 @@ defmodule PhoenixAppWeb.Router do get "/", PageController, :home get "/exception", PageController, :exception + get "/transaction", PageController, :transaction + get "/users", PageController, :users 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..1cb8a9c3 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}, + {: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,15 @@ defmodule PhoenixApp.MixProject do {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, {:bypass, "~> 2.1", only: :test}, - {:hackney, "~> 1.18", only: :test}, - {:sentry, path: "../.."} + {:opentelemetry, "~> 1.4"}, + {:opentelemetry_api, "~> 1.3"}, + {:opentelemetry_phoenix, "~> 1.2"}, + {:opentelemetry_bandit, "~> 0.1.4", github: "solnic/opentelemetry-bandit"}, + {:opentelemetry_ecto, "~> 1.2"}, + + {:sentry, path: "../.."}, + {:hackney, "~> 1.18"} ] end diff --git a/test_integrations/phoenix_app/mix.lock b/test_integrations/phoenix_app/mix.lock index a14316ee..650820c0 100644 --- a/test_integrations/phoenix_app/mix.lock +++ b/test_integrations/phoenix_app/mix.lock @@ -2,6 +2,7 @@ "bandit": {:hex, :bandit, "1.6.1", "9e01b93d72ddc21d8c576a704949e86ee6cde7d11270a1d3073787876527a48f", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5a904bf010ea24b67979835e0507688e31ac873d4ffc8ed0e5413e8d77455031"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "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"}, @@ -9,13 +10,19 @@ "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.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [: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", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, + "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.17.5", "fbee5c17ff6afd8e9ded519b0abb363926c65d30b27577232bb066b2a79957b8", [: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", "3b54734d998cbd032ac59403c36acf4e019670e8b6ceef9c6c33d8986c4e9704"}, + "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.27.1", "73fc0b3dc3b058a77a2b3771f82a6af2ddcf370b069906968a34083d2ffd2884", [: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", "79ef5756451cfb022e8013e1ed00d0f8f7d1333c19502c394dc16b15cfb4e9b4"}, "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"}, "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]}, "hpax": {:hex, :hpax, "1.0.1", "c857057f89e8bd71d97d9042e009df2a42705d6d690d54eca84c8b29af0787b0", [:mix], [], "hexpm", "4e2d5a4f76ae1e3048f35ae7adb1641c36265510a2d4638157fbcb53dda38445"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -26,6 +33,14 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, + "opentelemetry_bandit": {:git, "https://github.com/solnic/opentelemetry-bandit.git", "1e00505fb3bb02001a3400f8a807cd1c7f7f957d", []}, + "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_phoenix": {:hex, :opentelemetry_phoenix, "1.2.0", "b8a53ee595b24970571a7d2fcaef3e4e1a021c68e97cac163ca5d9875fad5e9f", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.0", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "acab991d14ed3efc3f780c5a20cabba27149cf731005b1cc6454c160859debe5"}, + "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, "0.2.0", "b67fe459c2938fcab341cb0951c44860c62347c005ace1b50f8402576f241435", [:mix, :rebar3], [], "hexpm", "d61fa1f5639ee8668d74b527e6806e0503efc55a42db7b5f39939d84c07d6895"}, + "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"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, diff --git a/test_integrations/phoenix_app/priv/repo/migrations/20240924163953_create_users.exs b/test_integrations/phoenix_app/priv/repo/migrations/20240924163953_create_users.exs new file mode 100644 index 00000000..5ac933c0 --- /dev/null +++ b/test_integrations/phoenix_app/priv/repo/migrations/20240924163953_create_users.exs @@ -0,0 +1,11 @@ +defmodule PhoenixApp.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users) do + add :name, :string + + timestamps() + end + 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..dffdd822 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") - 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..31460ddf --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs @@ -0,0 +1,67 @@ +defmodule Sentry.Integrations.Phoenix.TransactionTest do + use PhoenixAppWeb.ConnCase, async: true + + import Sentry.TestHelpers + + setup do + put_test_config(dsn: "http://public:secret@localhost:8080/1") + + Sentry.Test.start_collecting_sentry_reports() + end + + test "GET /transaction", %{conn: conn} do + get(conn, ~p"/transaction") + + transactions = Sentry.Test.pop_sentry_transactions() + + assert length(transactions) == 1 + + assert [transaction] = transactions + + assert transaction.transaction == "Elixir.PhoenixAppWeb.PageController#transaction" + assert transaction.transaction_info == %{source: "view"} + + trace = transaction.contexts.trace + assert trace.origin == "opentelemetry_phoenix" + assert trace.op == "http.server" + assert trace.data == %{"http.response.status_code" => 200} + + assert transaction.request.env == %{"SERVER_NAME" => "www.example.com", "SERVER_PORT" => 80} + assert transaction.request.url == "/transaction" + assert transaction.request.method == "GET" + + assert [span] = transaction.spans + + assert span.op == "test_span" + assert span.trace_id == trace.trace_id + assert span.parent_span_id == trace.span_id + end + + test "GET /users", %{conn: conn} do + get(conn, ~p"/users") + + transactions = Sentry.Test.pop_sentry_transactions() + + assert length(transactions) == 1 + + assert [transaction] = transactions + + assert transaction.transaction == "Elixir.PhoenixAppWeb.PageController#users" + assert transaction.transaction_info == %{source: "view"} + + trace = transaction.contexts.trace + assert trace.origin == "opentelemetry_phoenix" + assert trace.op == "http.server" + assert trace.data == %{"http.response.status_code" => 200} + + assert transaction.request.env == %{"SERVER_NAME" => "www.example.com", "SERVER_PORT" => 80} + assert transaction.request.url == "/users" + assert transaction.request.method == "GET" + + assert [span] = transaction.spans + + assert span.op == "phoenix_app.repo.query:users" + assert span.trace_id == trace.trace_id + assert span.parent_span_id == trace.span_id + end +end From a7b030bc88a1e267df2bd7af462791a465c86798 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 06:12:35 +0000 Subject: [PATCH 03/39] Move SpanStorage to its own file --- lib/sentry/application.ex | 2 +- lib/sentry/telemetry/span_processor.ex | 68 +------------------------- lib/sentry/telemetry/span_storage.ex | 65 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 68 deletions(-) create mode 100644 lib/sentry/telemetry/span_storage.ex diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index daf6ca8f..19034538 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -27,7 +27,7 @@ defmodule Sentry.Application do Sentry.Sources, Sentry.Dedupe, Sentry.ClientReport.Sender, - Sentry.Telemetry.SpanProcessor.SpanStorage, + Sentry.Telemetry.SpanStorage, {Sentry.Integrations.CheckInIDMappings, [ max_expected_check_in_time: diff --git a/lib/sentry/telemetry/span_processor.ex b/lib/sentry/telemetry/span_processor.ex index 6155ef81..76022b90 100644 --- a/lib/sentry/telemetry/span_processor.ex +++ b/lib/sentry/telemetry/span_processor.ex @@ -6,73 +6,7 @@ defmodule Sentry.Telemetry.SpanProcessor do @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") Record.defrecordp(:span, @fields) - alias Sentry.{Span, Transaction} - - defmodule SpanStorage do - use GenServer - - def start_link(_opts) do - GenServer.start_link(__MODULE__, nil, name: __MODULE__) - end - - def init(_) do - {:ok, %{root_spans: %{}, child_spans: %{}}} - end - - def store_span(span_data) do - GenServer.call(__MODULE__, {:store_span, span_data}) - end - - def get_root_span(span_id) do - GenServer.call(__MODULE__, {:get_root_span, span_id}) - end - - def get_child_spans(parent_span_id) do - GenServer.call(__MODULE__, {:get_child_spans, parent_span_id}) - end - - def update_span(span_data) do - GenServer.call(__MODULE__, {:update_span, span_data}) - end - - def handle_call({:store_span, span_data}, _from, state) do - if span_data[:parent_span_id] == :undefined do - new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) - {:reply, :ok, new_state} - else - new_state = - update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> - (spans || []) ++ [span_data] - end) - - {:reply, :ok, new_state} - end - end - - def handle_call({:get_root_span, span_id}, _from, state) do - {:reply, state.root_spans[span_id], state} - end - - def handle_call({:get_child_spans, parent_span_id}, _from, state) do - {:reply, state.child_spans[parent_span_id] || [], state} - end - - def handle_call({:update_span, span_data}, _from, state) do - if span_data[:parent_span_id] == :undefined do - new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) - {:reply, :ok, new_state} - else - new_state = - update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> - Enum.map(spans || [], fn span -> - if span[:span_id] == span_data[:span_id], do: span_data, else: span - end) - end) - - {:reply, :ok, new_state} - end - end - end + alias Sentry.{Span, Transaction, Telemetry.SpanStorage} @impl true def on_start(_ctx, otel_span, _config) do diff --git a/lib/sentry/telemetry/span_storage.ex b/lib/sentry/telemetry/span_storage.ex new file mode 100644 index 00000000..ef55f0f1 --- /dev/null +++ b/lib/sentry/telemetry/span_storage.ex @@ -0,0 +1,65 @@ +defmodule Sentry.Telemetry.SpanStorage do + use GenServer + + def start_link(_opts) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + def init(_) do + {:ok, %{root_spans: %{}, child_spans: %{}}} + end + + def store_span(span_data) do + GenServer.call(__MODULE__, {:store_span, span_data}) + end + + def get_root_span(span_id) do + GenServer.call(__MODULE__, {:get_root_span, span_id}) + end + + def get_child_spans(parent_span_id) do + GenServer.call(__MODULE__, {:get_child_spans, parent_span_id}) + end + + def update_span(span_data) do + GenServer.call(__MODULE__, {:update_span, span_data}) + end + + def handle_call({:store_span, span_data}, _from, state) do + if span_data[:parent_span_id] == :undefined do + new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) + {:reply, :ok, new_state} + else + new_state = + update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> + (spans || []) ++ [span_data] + end) + + {:reply, :ok, new_state} + end + end + + def handle_call({:get_root_span, span_id}, _from, state) do + {:reply, state.root_spans[span_id], state} + end + + def handle_call({:get_child_spans, parent_span_id}, _from, state) do + {:reply, state.child_spans[parent_span_id] || [], state} + end + + def handle_call({:update_span, span_data}, _from, state) do + if span_data[:parent_span_id] == :undefined do + new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) + {:reply, :ok, new_state} + else + new_state = + update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> + Enum.map(spans || [], fn span -> + if span[:span_id] == span_data[:span_id], do: span_data, else: span + end) + end) + + {:reply, :ok, new_state} + end + end +end From 0b84a81bcd8cec22fe99558b2dae7a37af7fd0b6 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 06:23:16 +0000 Subject: [PATCH 04/39] Rename Sentry.Telemetry => Sentry.Opentelemetry --- config/config.exs | 2 +- lib/sentry/application.ex | 2 +- lib/sentry/{telemetry => opentelemetry}/span_processor.ex | 4 ++-- lib/sentry/{telemetry => opentelemetry}/span_storage.ex | 2 +- .../{telemetry => opentelemetry}/span_processor_test.exs | 2 +- test_integrations/phoenix_app/config/config.exs | 3 +-- 6 files changed, 7 insertions(+), 8 deletions(-) rename lib/sentry/{telemetry => opentelemetry}/span_processor.ex (98%) rename lib/sentry/{telemetry => opentelemetry}/span_storage.ex (97%) rename test/sentry/{telemetry => opentelemetry}/span_processor_test.exs (98%) diff --git a/config/config.exs b/config/config.exs index 3d97efd5..70bcc7be 100644 --- a/config/config.exs +++ b/config/config.exs @@ -15,6 +15,6 @@ if config_env() == :test do config :logger, backends: [] end -config :opentelemetry, span_processor: {Sentry.Telemetry.SpanProcessor, []} +config :opentelemetry, span_processor: {Sentry.Opentelemetry.SpanProcessor, []} config :phoenix, :json_library, Jason diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 19034538..7eb56571 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -27,7 +27,7 @@ defmodule Sentry.Application do Sentry.Sources, Sentry.Dedupe, Sentry.ClientReport.Sender, - Sentry.Telemetry.SpanStorage, + Sentry.Opentelemetry.SpanStorage, {Sentry.Integrations.CheckInIDMappings, [ max_expected_check_in_time: diff --git a/lib/sentry/telemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex similarity index 98% rename from lib/sentry/telemetry/span_processor.ex rename to lib/sentry/opentelemetry/span_processor.ex index 76022b90..758fadd4 100644 --- a/lib/sentry/telemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -1,4 +1,4 @@ -defmodule Sentry.Telemetry.SpanProcessor do +defmodule Sentry.Opentelemetry.SpanProcessor do @behaviour :otel_span_processor require Record @@ -6,7 +6,7 @@ defmodule Sentry.Telemetry.SpanProcessor do @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") Record.defrecordp(:span, @fields) - alias Sentry.{Span, Transaction, Telemetry.SpanStorage} + alias Sentry.{Span, Transaction, Opentelemetry.SpanStorage} @impl true def on_start(_ctx, otel_span, _config) do diff --git a/lib/sentry/telemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex similarity index 97% rename from lib/sentry/telemetry/span_storage.ex rename to lib/sentry/opentelemetry/span_storage.ex index ef55f0f1..fe9b8778 100644 --- a/lib/sentry/telemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -1,4 +1,4 @@ -defmodule Sentry.Telemetry.SpanStorage do +defmodule Sentry.Opentelemetry.SpanStorage do use GenServer def start_link(_opts) do diff --git a/test/sentry/telemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs similarity index 98% rename from test/sentry/telemetry/span_processor_test.exs rename to test/sentry/opentelemetry/span_processor_test.exs index ea28bcc5..071b4962 100644 --- a/test/sentry/telemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -1,4 +1,4 @@ -defmodule Sentry.Telemetry.SpanProcessorTest do +defmodule Sentry.Opentelemetry.SpanProcessorTest do use Sentry.Case, async: false import Sentry.TestHelpers diff --git a/test_integrations/phoenix_app/config/config.exs b/test_integrations/phoenix_app/config/config.exs index 32f7435e..fea6538d 100644 --- a/test_integrations/phoenix_app/config/config.exs +++ b/test_integrations/phoenix_app/config/config.exs @@ -61,8 +61,7 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason -config :opentelemetry, - span_processor: {Sentry.Telemetry.SpanProcessor, []} +config :opentelemetry, span_processor: {Sentry.Opentelemetry.SpanProcessor, []} # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. From c001d1b8a65abe4b6a4f6a1e61563e46e576bb66 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:23:46 +0000 Subject: [PATCH 05/39] Introduce SpanRecord to simplify processing --- lib/sentry/opentelemetry/span_processor.ex | 211 +++++++++++---------- lib/sentry/opentelemetry/span_storage.ex | 14 +- 2 files changed, 120 insertions(+), 105 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 758fadd4..8bc017d1 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -1,16 +1,43 @@ defmodule Sentry.Opentelemetry.SpanProcessor do @behaviour :otel_span_processor - require Record + alias Sentry.{Span, Transaction, Opentelemetry.SpanStorage} - @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") - Record.defrecordp(:span, @fields) + defmodule SpanRecord do + require Record - alias Sentry.{Span, Transaction, Opentelemetry.SpanStorage} + @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") + Record.defrecordp(:span, @fields) + + defstruct @fields ++ [:origin] + + def new(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(origin: origin, attributes: attributes) + |> Map.new() + + struct(__MODULE__, attrs) + end + end @impl true def on_start(_ctx, otel_span, _config) do - span_record = span(otel_span) + span_record = SpanRecord.new(otel_span) SpanStorage.store_span(span_record) @@ -19,13 +46,13 @@ defmodule Sentry.Opentelemetry.SpanProcessor do @impl true def on_end(otel_span, _config) do - span_record = span(otel_span) + span_record = SpanRecord.new(otel_span) SpanStorage.update_span(span_record) - if span_record[:parent_span_id] == :undefined do - root_span = SpanStorage.get_root_span(span_record[:span_id]) - child_spans = SpanStorage.get_child_spans(span_record[:span_id]) + if span_record.parent_span_id == :undefined do + root_span = SpanStorage.get_root_span(span_record.span_id) + child_spans = SpanStorage.get_child_spans(span_record.span_id) transaction = transaction_from_root_span(root_span, child_spans) Sentry.send_transaction(transaction) @@ -40,33 +67,21 @@ defmodule Sentry.Opentelemetry.SpanProcessor do end defp transaction_from_root_span(root_span, child_spans) do - {:attributes, _, _, _, attributes} = root_span[:attributes] - - build_transaction(attributes, root_span, child_spans) - end - - defp build_transaction(attributes, root_span, child_spans) when is_map(attributes) do - trace_id = cast_trace_id(root_span[:trace_id]) - - case root_span[:instrumentation_scope] do - {:instrumentation_scope, origin, _version, _} -> - build_transaction(origin, trace_id, root_span, child_spans, attributes) + trace_id = cast_trace_id(root_span.trace_id) - :undefined -> - build_transaction(trace_id, root_span, child_spans) - end + build_transaction(trace_id, root_span, child_spans) end - defp build_transaction(trace_id, root_span, child_spans) when is_binary(trace_id) do + defp build_transaction(trace_id, %SpanRecord{origin: :undefined} = root_span, child_spans) do Transaction.new(%{ - transaction: root_span[:name], - start_timestamp: cast_timestamp(root_span[:start_time]), - timestamp: cast_timestamp(root_span[:end_time]), + transaction: root_span.name, + start_timestamp: cast_timestamp(root_span.start_time), + timestamp: cast_timestamp(root_span.end_time), contexts: %{ trace: %{ trace_id: trace_id, - span_id: cast_span_id(root_span[:span_id]), - op: root_span[:name] + span_id: cast_span_id(root_span.span_id), + op: root_span.name } }, spans: Enum.map([root_span | child_spans], &build_span(&1, trace_id)) @@ -74,26 +89,24 @@ defmodule Sentry.Opentelemetry.SpanProcessor do end defp build_transaction( - "opentelemetry_ecto" = origin, trace_id, - root_span, - child_spans, - attributes + %SpanRecord{attributes: attributes, origin: "opentelemetry_ecto"} = root_span, + child_spans ) do Transaction.new(%{ - transaction: root_span[:name], - start_timestamp: cast_timestamp(root_span[:start_time]), - timestamp: cast_timestamp(root_span[:end_time]), + transaction: root_span.name, + start_timestamp: cast_timestamp(root_span.start_time), + timestamp: cast_timestamp(root_span.end_time), transaction_info: %{ source: "db" }, contexts: %{ trace: %{ trace_id: trace_id, - span_id: cast_span_id(root_span[:span_id]), - parent_span_id: cast_span_id(root_span[:parent_span_id]), + span_id: cast_span_id(root_span.span_id), + parent_span_id: cast_span_id(root_span.parent_span_id), op: "db", - origin: origin + origin: root_span.origin } }, platform: "elixir", @@ -119,19 +132,17 @@ defmodule Sentry.Opentelemetry.SpanProcessor do end defp build_transaction( - "opentelemetry_phoenix" = origin, trace_id, - root_span, - child_spans, - attributes + %SpanRecord{attributes: attributes, origin: "opentelemetry_phoenix"} = root_span, + child_spans ) do name = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" - trace = build_trace_context(trace_id, origin, root_span, attributes) + trace = build_trace_context(trace_id, root_span) Transaction.new(%{ transaction: name, - start_timestamp: cast_timestamp(root_span[:start_time]), - timestamp: cast_timestamp(root_span[:end_time]), + start_timestamp: cast_timestamp(root_span.start_time), + timestamp: cast_timestamp(root_span.end_time), transaction_info: %{ source: "view" }, @@ -168,11 +179,15 @@ defmodule Sentry.Opentelemetry.SpanProcessor do }) end - defp build_transaction("opentelemetry_bandit", trace_id, root_span, child_spans, attributes) do + defp build_transaction( + trace_id, + %SpanRecord{attributes: attributes, origin: "opentelemetry_bandit"} = root_span, + child_spans + ) do %Sentry.Transaction{ event_id: Sentry.UUID.uuid4_hex(), - start_timestamp: cast_timestamp(root_span[:start_time]), - timestamp: cast_timestamp(root_span[:end_time]), + start_timestamp: cast_timestamp(root_span.start_time), + timestamp: cast_timestamp(root_span.end_time), transaction: attributes[:"http.target"], transaction_info: %{ source: "url" @@ -180,8 +195,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do contexts: %{ trace: %{ trace_id: trace_id, - span_id: cast_span_id(root_span[:span_id]), - parent_span_id: cast_span_id(root_span[:parent_span_id]) + span_id: cast_span_id(root_span.span_id), + parent_span_id: cast_span_id(root_span.parent_span_id) } }, platform: "elixir", @@ -205,10 +220,13 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_trace_context(trace_id, origin, root_span, attributes) do + defp build_trace_context( + trace_id, + %SpanRecord{origin: origin, attributes: attributes} = root_span + ) do %{ trace_id: trace_id, - span_id: cast_span_id(root_span[:span_id]), + span_id: cast_span_id(root_span.span_id), parent_span_id: nil, op: "http.server", origin: origin, @@ -218,66 +236,60 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_span(span_record, trace_id) do - {:attributes, _, _, _, attributes} = span_record[:attributes] - - case span_record[:instrumentation_scope] do - {:instrumentation_scope, origin, _version, _} -> - build_span(origin, span_record, trace_id, attributes) - - :undefined -> - build_span(:custom, span_record, trace_id, attributes) - end - end - - defp build_span("opentelemetry_phoenix" = origin, span_record, trace_id, attributes) do + defp build_span( + %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record, + trace_id + ) do op = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" %Span{ op: op, - start_timestamp: cast_timestamp(span_record[:start_time]), - timestamp: cast_timestamp(span_record[:end_time]), + start_timestamp: cast_timestamp(span_record.start_time), + timestamp: cast_timestamp(span_record.end_time), trace_id: trace_id, - span_id: cast_span_id(span_record[:span_id]), - parent_span_id: cast_span_id(span_record[:parent_span_id]), + span_id: cast_span_id(span_record.span_id), + parent_span_id: cast_span_id(span_record.parent_span_id), description: attributes[:"http.route"], - origin: origin + origin: span_record.origin } end - defp build_span("phoenix_app", span_record, trace_id, _attributes) do + defp build_span(%SpanRecord{origin: "phoenix_app"} = span_record, trace_id) do %Span{ trace_id: trace_id, - op: span_record[:name], - start_timestamp: cast_timestamp(span_record[:start_time]), - timestamp: cast_timestamp(span_record[:end_time]), - span_id: cast_span_id(span_record[:span_id]), - parent_span_id: cast_span_id(span_record[:parent_span_id]) + op: span_record.name, + start_timestamp: cast_timestamp(span_record.start_time), + timestamp: cast_timestamp(span_record.end_time), + span_id: cast_span_id(span_record.span_id), + parent_span_id: cast_span_id(span_record.parent_span_id) } end - defp build_span("opentelemetry_bandit" = origin, span_record, trace_id, _attributes) do + defp build_span(%SpanRecord{origin: "opentelemetry_bandit"} = span_record, trace_id) do %Span{ trace_id: trace_id, - op: span_record[:name], - start_timestamp: cast_timestamp(span_record[:start_time]), - timestamp: cast_timestamp(span_record[:end_time]), - span_id: cast_span_id(span_record[:span_id]), - parent_span_id: cast_span_id(span_record[:parent_span_id]), - description: span_record[:name], - origin: origin + op: span_record.name, + start_timestamp: cast_timestamp(span_record.start_time), + timestamp: cast_timestamp(span_record.end_time), + span_id: cast_span_id(span_record.span_id), + parent_span_id: cast_span_id(span_record.parent_span_id), + description: span_record.name, + origin: span_record.origin } end - defp build_span("opentelemetry_ecto" = origin, span_record, trace_id, attributes) do + defp build_span( + %SpanRecord{origin: "opentelemetry_ecto", attributes: attributes} = span_record, + trace_id + ) do %Span{ trace_id: trace_id, - op: span_record[:name], - start_timestamp: cast_timestamp(span_record[:start_time]), - timestamp: cast_timestamp(span_record[:end_time]), - span_id: cast_span_id(span_record[:span_id]), - parent_span_id: cast_span_id(span_record[:parent_span_id]), - origin: origin, + op: span_record.name, + start_timestamp: cast_timestamp(span_record.start_time), + timestamp: cast_timestamp(span_record.end_time), + span_id: cast_span_id(span_record.span_id), + parent_span_id: cast_span_id(span_record.parent_span_id), + origin: span_record.origin, data: %{ "db.system" => attributes[:"db.system"], "db.name" => attributes[:"db.name"] @@ -285,14 +297,17 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_span(:custom, span_record, trace_id, _attributes) do + defp build_span( + %SpanRecord{origin: :undefined, attributes: _attributes} = span_record, + trace_id + ) do %Span{ trace_id: trace_id, - op: span_record[:name], - start_timestamp: cast_timestamp(span_record[:start_time]), - timestamp: cast_timestamp(span_record[:end_time]), - span_id: cast_span_id(span_record[:span_id]), - parent_span_id: cast_span_id(span_record[:parent_span_id]) + op: span_record.name, + start_timestamp: cast_timestamp(span_record.start_time), + timestamp: cast_timestamp(span_record.end_time), + span_id: cast_span_id(span_record.span_id), + parent_span_id: cast_span_id(span_record.parent_span_id) } end diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index fe9b8778..a3fc5679 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -26,12 +26,12 @@ defmodule Sentry.Opentelemetry.SpanStorage do end def handle_call({:store_span, span_data}, _from, state) do - if span_data[:parent_span_id] == :undefined do - new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) + if span_data.parent_span_id == :undefined do + new_state = put_in(state, [:root_spans, span_data.span_id], span_data) {:reply, :ok, new_state} else new_state = - update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> + update_in(state, [:child_spans, span_data.parent_span_id], fn spans -> (spans || []) ++ [span_data] end) @@ -48,14 +48,14 @@ defmodule Sentry.Opentelemetry.SpanStorage do end def handle_call({:update_span, span_data}, _from, state) do - if span_data[:parent_span_id] == :undefined do - new_state = put_in(state, [:root_spans, span_data[:span_id]], span_data) + if span_data.parent_span_id == :undefined do + new_state = put_in(state, [:root_spans, span_data.span_id], span_data) {:reply, :ok, new_state} else new_state = - update_in(state, [:child_spans, span_data[:parent_span_id]], fn spans -> + update_in(state, [:child_spans, span_data.parent_span_id], fn spans -> Enum.map(spans || [], fn span -> - if span[:span_id] == span_data[:span_id], do: span_data, else: span + if span.span_id == span_data.span_id, do: span_data, else: span end) end) From ddecba0214f956c3cdf55b66440ab1e291a720ab Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:27:06 +0000 Subject: [PATCH 06/39] Move casting trace_id to the SpanRecord struct --- lib/sentry/opentelemetry/span_processor.ex | 49 ++++++++++++---------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 8bc017d1..1da96f8f 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -28,11 +28,24 @@ defmodule Sentry.Opentelemetry.SpanProcessor do attrs = otel_attrs |> Keyword.delete(:attributes) - |> Keyword.merge(origin: origin, attributes: attributes) + |> Keyword.merge( + trace_id: cast_trace_id(otel_attrs[:trace_id]), + origin: origin, + attributes: attributes + ) |> Map.new() struct(__MODULE__, attrs) end + + defp cast_trace_id(trace_id), do: bytes_to_hex(trace_id, 32) + + 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 @impl true @@ -67,29 +80,26 @@ defmodule Sentry.Opentelemetry.SpanProcessor do end defp transaction_from_root_span(root_span, child_spans) do - trace_id = cast_trace_id(root_span.trace_id) - - build_transaction(trace_id, root_span, child_spans) + build_transaction(root_span, child_spans) end - defp build_transaction(trace_id, %SpanRecord{origin: :undefined} = root_span, child_spans) do + defp build_transaction(%SpanRecord{origin: :undefined} = root_span, child_spans) do Transaction.new(%{ transaction: root_span.name, start_timestamp: cast_timestamp(root_span.start_time), timestamp: cast_timestamp(root_span.end_time), contexts: %{ trace: %{ - trace_id: trace_id, + trace_id: root_span.trace_id, span_id: cast_span_id(root_span.span_id), op: root_span.name } }, - spans: Enum.map([root_span | child_spans], &build_span(&1, trace_id)) + spans: Enum.map([root_span | child_spans], &build_span(&1, root_span.trace_id)) }) end defp build_transaction( - trace_id, %SpanRecord{attributes: attributes, origin: "opentelemetry_ecto"} = root_span, child_spans ) do @@ -102,7 +112,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do }, contexts: %{ trace: %{ - trace_id: trace_id, + trace_id: root_span.trace_id, span_id: cast_span_id(root_span.span_id), parent_span_id: cast_span_id(root_span.parent_span_id), op: "db", @@ -127,17 +137,16 @@ defmodule Sentry.Opentelemetry.SpanProcessor do "query_time_microseconds" => attributes[:query_time_microseconds] }, measurements: %{}, - spans: Enum.map(child_spans, &build_span(&1, trace_id)) + spans: Enum.map(child_spans, &build_span(&1, root_span.trace_id)) }) end defp build_transaction( - trace_id, %SpanRecord{attributes: attributes, origin: "opentelemetry_phoenix"} = root_span, child_spans ) do name = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" - trace = build_trace_context(trace_id, root_span) + trace = build_trace_context(root_span) Transaction.new(%{ transaction: name, @@ -175,12 +184,11 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } }, measurements: %{}, - spans: Enum.map(child_spans, &build_span(&1, trace_id)) + spans: Enum.map(child_spans, &build_span(&1, root_span.trace_id)) }) end defp build_transaction( - trace_id, %SpanRecord{attributes: attributes, origin: "opentelemetry_bandit"} = root_span, child_spans ) do @@ -194,7 +202,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do }, contexts: %{ trace: %{ - trace_id: trace_id, + trace_id: root_span.trace_id, span_id: cast_span_id(root_span.span_id), parent_span_id: cast_span_id(root_span.parent_span_id) } @@ -216,16 +224,13 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } }, measurements: %{}, - spans: Enum.map(child_spans, &build_span(&1, trace_id)) + spans: Enum.map(child_spans, &build_span(&1, root_span.trace_id)) } end - defp build_trace_context( - trace_id, - %SpanRecord{origin: origin, attributes: attributes} = root_span - ) do + defp build_trace_context(%SpanRecord{origin: origin, attributes: attributes} = root_span) do %{ - trace_id: trace_id, + trace_id: root_span.trace_id, span_id: cast_span_id(root_span.span_id), parent_span_id: nil, op: "http.server", @@ -315,8 +320,6 @@ defmodule Sentry.Opentelemetry.SpanProcessor do 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 From 9777500adf8aba6f85981b450d6d6da1ba8b6aa4 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:30:31 +0000 Subject: [PATCH 07/39] Move casting of span ids to the SpanRecord struct --- lib/sentry/opentelemetry/span_processor.ex | 51 ++++++++++------------ lib/sentry/opentelemetry/span_storage.ex | 4 +- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 1da96f8f..271a5163 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -30,6 +30,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do |> 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, attributes: attributes ) @@ -38,6 +40,10 @@ defmodule Sentry.Opentelemetry.SpanProcessor do struct(__MODULE__, attrs) 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 bytes_to_hex(bytes, length) do @@ -63,7 +69,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do SpanStorage.update_span(span_record) - if span_record.parent_span_id == :undefined do + if span_record.parent_span_id == nil do root_span = SpanStorage.get_root_span(span_record.span_id) child_spans = SpanStorage.get_child_spans(span_record.span_id) @@ -91,7 +97,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do contexts: %{ trace: %{ trace_id: root_span.trace_id, - span_id: cast_span_id(root_span.span_id), + span_id: root_span.span_id, op: root_span.name } }, @@ -113,8 +119,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do contexts: %{ trace: %{ trace_id: root_span.trace_id, - span_id: cast_span_id(root_span.span_id), - parent_span_id: cast_span_id(root_span.parent_span_id), + span_id: root_span.span_id, + parent_span_id: root_span.parent_span_id, op: "db", origin: root_span.origin } @@ -203,8 +209,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do contexts: %{ trace: %{ trace_id: root_span.trace_id, - span_id: cast_span_id(root_span.span_id), - parent_span_id: cast_span_id(root_span.parent_span_id) + span_id: root_span.span_id, + parent_span_id: root_span.parent_span_id } }, platform: "elixir", @@ -231,7 +237,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do defp build_trace_context(%SpanRecord{origin: origin, attributes: attributes} = root_span) do %{ trace_id: root_span.trace_id, - span_id: cast_span_id(root_span.span_id), + span_id: root_span.span_id, parent_span_id: nil, op: "http.server", origin: origin, @@ -252,8 +258,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), trace_id: trace_id, - span_id: cast_span_id(span_record.span_id), - parent_span_id: cast_span_id(span_record.parent_span_id), + span_id: span_record.span_id, + parent_span_id: span_record.parent_span_id, description: attributes[:"http.route"], origin: span_record.origin } @@ -265,8 +271,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), - span_id: cast_span_id(span_record.span_id), - parent_span_id: cast_span_id(span_record.parent_span_id) + span_id: span_record.span_id, + parent_span_id: span_record.parent_span_id } end @@ -276,8 +282,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), - span_id: cast_span_id(span_record.span_id), - parent_span_id: cast_span_id(span_record.parent_span_id), + span_id: span_record.span_id, + parent_span_id: span_record.parent_span_id, description: span_record.name, origin: span_record.origin } @@ -292,8 +298,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), - span_id: cast_span_id(span_record.span_id), - parent_span_id: cast_span_id(span_record.parent_span_id), + span_id: span_record.span_id, + parent_span_id: span_record.parent_span_id, origin: span_record.origin, data: %{ "db.system" => attributes[:"db.system"], @@ -311,15 +317,11 @@ defmodule Sentry.Opentelemetry.SpanProcessor do op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), - span_id: cast_span_id(span_record.span_id), - parent_span_id: cast_span_id(span_record.parent_span_id) + span_id: span_record.span_id, + parent_span_id: span_record.parent_span_id } 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_timestamp(:undefined), do: nil defp cast_timestamp(nil), do: nil @@ -329,11 +331,4 @@ defmodule Sentry.Opentelemetry.SpanProcessor do 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 diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index a3fc5679..f38cd9ad 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -26,7 +26,7 @@ defmodule Sentry.Opentelemetry.SpanStorage do end def handle_call({:store_span, span_data}, _from, state) do - if span_data.parent_span_id == :undefined do + if span_data.parent_span_id == nil do new_state = put_in(state, [:root_spans, span_data.span_id], span_data) {:reply, :ok, new_state} else @@ -48,7 +48,7 @@ defmodule Sentry.Opentelemetry.SpanStorage do end def handle_call({:update_span, span_data}, _from, state) do - if span_data.parent_span_id == :undefined do + if span_data.parent_span_id == nil do new_state = put_in(state, [:root_spans, span_data.span_id], span_data) {:reply, :ok, new_state} else From 4fa80f204319bce4e5d831aafc42f4c5fc592857 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:33:14 +0000 Subject: [PATCH 08/39] No need to pass trace_id around anymore --- lib/sentry/opentelemetry/span_processor.ex | 35 +++++++++------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 271a5163..67a8dc77 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -101,7 +101,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do op: root_span.name } }, - spans: Enum.map([root_span | child_spans], &build_span(&1, root_span.trace_id)) + spans: Enum.map([root_span | child_spans], &build_span(&1)) }) end @@ -143,7 +143,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do "query_time_microseconds" => attributes[:query_time_microseconds] }, measurements: %{}, - spans: Enum.map(child_spans, &build_span(&1, root_span.trace_id)) + spans: Enum.map(child_spans, &build_span(&1)) }) end @@ -190,7 +190,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } }, measurements: %{}, - spans: Enum.map(child_spans, &build_span(&1, root_span.trace_id)) + spans: Enum.map(child_spans, &build_span(&1)) }) end @@ -230,7 +230,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } }, measurements: %{}, - spans: Enum.map(child_spans, &build_span(&1, root_span.trace_id)) + spans: Enum.map(child_spans, &build_span(&1)) } end @@ -248,8 +248,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do end defp build_span( - %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record, - trace_id + %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record ) do op = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" @@ -257,7 +256,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do op: op, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), - trace_id: trace_id, + trace_id: span_record.trace_id, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id, description: attributes[:"http.route"], @@ -265,9 +264,9 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_span(%SpanRecord{origin: "phoenix_app"} = span_record, trace_id) do + defp build_span(%SpanRecord{origin: "phoenix_app"} = span_record) do %Span{ - trace_id: trace_id, + trace_id: span_record.trace_id, op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), @@ -276,9 +275,9 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_span(%SpanRecord{origin: "opentelemetry_bandit"} = span_record, trace_id) do + defp build_span(%SpanRecord{origin: "opentelemetry_bandit"} = span_record) do %Span{ - trace_id: trace_id, + trace_id: span_record.trace_id, op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), @@ -289,12 +288,9 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_span( - %SpanRecord{origin: "opentelemetry_ecto", attributes: attributes} = span_record, - trace_id - ) do + defp build_span(%SpanRecord{origin: "opentelemetry_ecto", attributes: attributes} = span_record) do %Span{ - trace_id: trace_id, + trace_id: span_record.trace_id, op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), @@ -308,12 +304,9 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_span( - %SpanRecord{origin: :undefined, attributes: _attributes} = span_record, - trace_id - ) do + defp build_span(%SpanRecord{origin: :undefined, attributes: _attributes} = span_record) do %Span{ - trace_id: trace_id, + trace_id: span_record.trace_id, op: span_record.name, start_timestamp: cast_timestamp(span_record.start_time), timestamp: cast_timestamp(span_record.end_time), From f4330ec25657a6d333c1e9621258ed8c79cc852f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:34:22 +0000 Subject: [PATCH 09/39] Handle casting timestamps in the SpanRecord struct --- lib/sentry/opentelemetry/span_processor.ex | 58 +++++++++++----------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 67a8dc77..eb391b04 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -33,6 +33,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do 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: attributes ) |> Map.new() @@ -46,6 +48,16 @@ defmodule Sentry.Opentelemetry.SpanProcessor do 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 @@ -92,8 +104,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do defp build_transaction(%SpanRecord{origin: :undefined} = root_span, child_spans) do Transaction.new(%{ transaction: root_span.name, - start_timestamp: cast_timestamp(root_span.start_time), - timestamp: cast_timestamp(root_span.end_time), + start_timestamp: root_span.start_time, + timestamp: root_span.end_time, contexts: %{ trace: %{ trace_id: root_span.trace_id, @@ -111,8 +123,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do ) do Transaction.new(%{ transaction: root_span.name, - start_timestamp: cast_timestamp(root_span.start_time), - timestamp: cast_timestamp(root_span.end_time), + start_timestamp: root_span.start_time, + timestamp: root_span.end_time, transaction_info: %{ source: "db" }, @@ -156,8 +168,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do Transaction.new(%{ transaction: name, - start_timestamp: cast_timestamp(root_span.start_time), - timestamp: cast_timestamp(root_span.end_time), + start_timestamp: root_span.start_time, + timestamp: root_span.end_time, transaction_info: %{ source: "view" }, @@ -200,8 +212,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do ) do %Sentry.Transaction{ event_id: Sentry.UUID.uuid4_hex(), - start_timestamp: cast_timestamp(root_span.start_time), - timestamp: cast_timestamp(root_span.end_time), + start_timestamp: root_span.start_time, + timestamp: root_span.end_time, transaction: attributes[:"http.target"], transaction_info: %{ source: "url" @@ -254,8 +266,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do %Span{ op: op, - start_timestamp: cast_timestamp(span_record.start_time), - timestamp: cast_timestamp(span_record.end_time), + 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, @@ -268,8 +280,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do %Span{ trace_id: span_record.trace_id, op: span_record.name, - start_timestamp: cast_timestamp(span_record.start_time), - timestamp: cast_timestamp(span_record.end_time), + start_timestamp: span_record.start_time, + timestamp: span_record.end_time, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id } @@ -279,8 +291,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do %Span{ trace_id: span_record.trace_id, op: span_record.name, - start_timestamp: cast_timestamp(span_record.start_time), - timestamp: cast_timestamp(span_record.end_time), + start_timestamp: span_record.start_time, + timestamp: span_record.end_time, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id, description: span_record.name, @@ -292,8 +304,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do %Span{ trace_id: span_record.trace_id, op: span_record.name, - start_timestamp: cast_timestamp(span_record.start_time), - timestamp: cast_timestamp(span_record.end_time), + start_timestamp: span_record.start_time, + timestamp: span_record.end_time, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id, origin: span_record.origin, @@ -308,20 +320,10 @@ defmodule Sentry.Opentelemetry.SpanProcessor do %Span{ trace_id: span_record.trace_id, op: span_record.name, - start_timestamp: cast_timestamp(span_record.start_time), - timestamp: cast_timestamp(span_record.end_time), + start_timestamp: span_record.start_time, + timestamp: span_record.end_time, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id } end - - 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 end From c2c0bc991e7f3d5c39a47c477add5cf506220315 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:39:58 +0000 Subject: [PATCH 10/39] Extract SpanRecord into its own file --- lib/sentry/opentelemetry/span_processor.ex | 65 +--------------------- lib/sentry/opentelemetry/span_record.ex | 62 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 64 deletions(-) create mode 100644 lib/sentry/opentelemetry/span_record.ex diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index eb391b04..c3468486 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -1,70 +1,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do @behaviour :otel_span_processor - alias Sentry.{Span, Transaction, Opentelemetry.SpanStorage} - - defmodule SpanRecord do - require Record - - @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") - Record.defrecordp(:span, @fields) - - defstruct @fields ++ [:origin] - - def new(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: attributes - ) - |> Map.new() - - struct(__MODULE__, attrs) - 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 + alias Sentry.{Span, Transaction, Opentelemetry.SpanStorage, Opentelemetry.SpanRecord} @impl true def on_start(_ctx, otel_span, _config) do diff --git a/lib/sentry/opentelemetry/span_record.ex b/lib/sentry/opentelemetry/span_record.ex new file mode 100644 index 00000000..29d5dabd --- /dev/null +++ b/lib/sentry/opentelemetry/span_record.ex @@ -0,0 +1,62 @@ +defmodule Sentry.Opentelemetry.SpanRecord do + require Record + + @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") + Record.defrecordp(:span, @fields) + + defstruct @fields ++ [:origin] + + def new(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: attributes + ) + |> Map.new() + + struct(__MODULE__, attrs) + 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 From b3dceb6f16f999ceba5493ab82646a8683256b9c Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:42:21 +0000 Subject: [PATCH 11/39] Remove redundant function --- lib/sentry/opentelemetry/span_processor.ex | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index c3468486..10fc7910 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -22,7 +22,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do root_span = SpanStorage.get_root_span(span_record.span_id) child_spans = SpanStorage.get_child_spans(span_record.span_id) - transaction = transaction_from_root_span(root_span, child_spans) + transaction = build_transaction(root_span, child_spans) + Sentry.send_transaction(transaction) end @@ -34,10 +35,6 @@ defmodule Sentry.Opentelemetry.SpanProcessor do :ok end - defp transaction_from_root_span(root_span, child_spans) do - build_transaction(root_span, child_spans) - end - defp build_transaction(%SpanRecord{origin: :undefined} = root_span, child_spans) do Transaction.new(%{ transaction: root_span.name, From 2b68f86e6ccce241ba2ff03b43cfc8afc986dee7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:48:31 +0000 Subject: [PATCH 12/39] Move setting platform info to the Client --- lib/sentry/client.ex | 10 +++++++++- lib/sentry/opentelemetry/span_processor.ex | 16 ---------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 05dd5f63..8bc68133 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -287,7 +287,15 @@ defmodule Sentry.Client do @spec render_transaction(%Transaction{}) :: map() def render_transaction(%Transaction{} = transaction) do - Transaction.to_map(transaction) + transaction + |> Transaction.to_map() + |> Map.merge(%{ + platform: "elixir", + sdk: %{ + name: "sentry.elixir", + version: "10.7.1" + } + }) end defp render_exception(%Interfaces.Exception{} = exception) do diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 10fc7910..1c4c613b 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -71,11 +71,6 @@ defmodule Sentry.Opentelemetry.SpanProcessor do origin: root_span.origin } }, - platform: "elixir", - sdk: %{ - name: "sentry.elixir", - version: "10.7.1" - }, data: %{ "db.system" => attributes[:"db.system"], "db.name" => attributes[:"db.name"], @@ -110,11 +105,6 @@ defmodule Sentry.Opentelemetry.SpanProcessor do contexts: %{ trace: trace }, - platform: "elixir", - sdk: %{ - name: "sentry.elixir", - version: "10.7.1" - }, request: %{ url: attributes[:"http.target"], method: attributes[:"http.method"], @@ -145,7 +135,6 @@ defmodule Sentry.Opentelemetry.SpanProcessor do child_spans ) do %Sentry.Transaction{ - event_id: Sentry.UUID.uuid4_hex(), start_timestamp: root_span.start_time, timestamp: root_span.end_time, transaction: attributes[:"http.target"], @@ -159,11 +148,6 @@ defmodule Sentry.Opentelemetry.SpanProcessor do parent_span_id: root_span.parent_span_id } }, - platform: "elixir", - sdk: %{ - name: "sentry.elixir", - version: "10.7.1" - }, request: %{ url: attributes[:"http.url"], method: attributes[:"http.method"], From 64d0344315eb2df9c816d2ab0ac0e75474ee809e Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 07:54:02 +0000 Subject: [PATCH 13/39] Extract setting root span context --- lib/sentry/opentelemetry/span_processor.ex | 55 ++++++++++++---------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 1c4c613b..b483cb24 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -41,11 +41,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do start_timestamp: root_span.start_time, timestamp: root_span.end_time, contexts: %{ - trace: %{ - trace_id: root_span.trace_id, - span_id: root_span.span_id, - op: root_span.name - } + trace: build_trace_context(root_span) }, spans: Enum.map([root_span | child_spans], &build_span(&1)) }) @@ -63,13 +59,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do source: "db" }, contexts: %{ - trace: %{ - trace_id: root_span.trace_id, - span_id: root_span.span_id, - parent_span_id: root_span.parent_span_id, - op: "db", - origin: root_span.origin - } + trace: build_trace_context(root_span) }, data: %{ "db.system" => attributes[:"db.system"], @@ -92,18 +82,15 @@ defmodule Sentry.Opentelemetry.SpanProcessor do %SpanRecord{attributes: attributes, origin: "opentelemetry_phoenix"} = root_span, child_spans ) do - name = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" - trace = build_trace_context(root_span) - Transaction.new(%{ - transaction: name, + transaction: "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}", start_timestamp: root_span.start_time, timestamp: root_span.end_time, transaction_info: %{ source: "view" }, contexts: %{ - trace: trace + trace: build_trace_context(root_span) }, request: %{ url: attributes[:"http.target"], @@ -142,11 +129,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do source: "url" }, contexts: %{ - trace: %{ - trace_id: root_span.trace_id, - span_id: root_span.span_id, - parent_span_id: root_span.parent_span_id - } + trace: build_trace_context(root_span) }, request: %{ url: attributes[:"http.url"], @@ -164,19 +147,43 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end - defp build_trace_context(%SpanRecord{origin: origin, attributes: attributes} = root_span) do + defp build_trace_context( + %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = root_span + ) do %{ trace_id: root_span.trace_id, span_id: root_span.span_id, parent_span_id: nil, op: "http.server", - origin: origin, + origin: root_span.origin, data: %{ "http.response.status_code" => attributes[:"http.status_code"] } } end + defp build_trace_context(%SpanRecord{origin: "opentelemetry_ecto"} = root_span) do + %{ + trace_id: root_span.trace_id, + span_id: root_span.span_id, + parent_span_id: root_span.parent_span_id, + op: "db", + origin: root_span.origin, + data: root_span.attributes + } + end + + defp build_trace_context(%SpanRecord{attributes: attributes} = root_span) do + %{ + trace_id: root_span.trace_id, + span_id: root_span.span_id, + parent_span_id: nil, + op: root_span.name, + origin: root_span.origin, + data: attributes + } + end + defp build_span( %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record ) do From e9901dabbc30bb6f78a568b37c39b57995177e5b Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 09:39:19 +0000 Subject: [PATCH 14/39] Improve ecto span/transaction --- lib/sentry/client.ex | 1 + lib/sentry/opentelemetry/span_processor.ex | 82 ++++++++----------- lib/sentry/opentelemetry/span_record.ex | 9 +- lib/sentry/transaction.ex | 2 - .../lib/phoenix_app/application.ex | 2 +- .../phoenix_app/test/phoenix_app/repo_test.ex | 27 ++++++ .../controllers/transaction_test.exs | 3 +- 7 files changed, 73 insertions(+), 53 deletions(-) create mode 100644 test_integrations/phoenix_app/test/phoenix_app/repo_test.ex diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 8bc68133..995d8349 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -287,6 +287,7 @@ defmodule Sentry.Client do @spec render_transaction(%Transaction{}) :: map() def render_transaction(%Transaction{} = transaction) do + # IO.inspect(transaction, label: "transaction") transaction |> Transaction.to_map() |> Map.merge(%{ diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index b483cb24..539ad729 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -47,10 +47,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do }) end - defp build_transaction( - %SpanRecord{attributes: attributes, origin: "opentelemetry_ecto"} = root_span, - child_spans - ) do + defp build_transaction(%SpanRecord{origin: "opentelemetry_ecto"} = root_span, child_spans) do Transaction.new(%{ transaction: root_span.name, start_timestamp: root_span.start_time, @@ -61,18 +58,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do contexts: %{ trace: build_trace_context(root_span) }, - data: %{ - "db.system" => attributes[:"db.system"], - "db.name" => attributes[:"db.name"], - "db.instance" => attributes[:"db.instance"], - "db.type" => attributes[:"db.type"], - "db.url" => attributes[:"db.url"], - "total_time_microseconds" => attributes[:total_time_microseconds], - "idle_time_microseconds" => attributes[:idle_time_microseconds], - "decode_time_microseconds" => attributes[:decode_time_microseconds], - "queue_time_microseconds" => attributes[:queue_time_microseconds], - "query_time_microseconds" => attributes[:query_time_microseconds] - }, + data: root_span.attributes, measurements: %{}, spans: Enum.map(child_spans, &build_span(&1)) }) @@ -83,7 +69,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do child_spans ) do Transaction.new(%{ - transaction: "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}", + transaction: "#{attributes["phoenix.plug"]}##{attributes["phoenix.action"]}", start_timestamp: root_span.start_time, timestamp: root_span.end_time, transaction_info: %{ @@ -93,23 +79,23 @@ defmodule Sentry.Opentelemetry.SpanProcessor do trace: build_trace_context(root_span) }, request: %{ - url: attributes[:"http.target"], - method: attributes[:"http.method"], + url: attributes["http.target"], + method: attributes["http.method"], headers: %{ - "User-Agent" => attributes[:"http.user_agent"] + "User-Agent" => attributes["http.user_agent"] }, env: %{ - "SERVER_NAME" => attributes[:"net.host.name"], - "SERVER_PORT" => attributes[:"net.host.port"] + "SERVER_NAME" => attributes["net.host.name"], + "SERVER_PORT" => attributes["net.host.port"] } }, data: %{ - "http.response.status_code" => attributes[:"http.status_code"], - "method" => attributes[:"http.method"], - "path" => attributes[:"http.target"], + "http.response.status_code" => attributes["http.status_code"], + "method" => attributes["http.method"], + "path" => attributes["http.target"], "params" => %{ - "controller" => attributes[:"phoenix.plug"], - "action" => attributes[:"phoenix.action"] + "controller" => attributes["phoenix.plug"], + "action" => attributes["phoenix.action"] } }, measurements: %{}, @@ -121,10 +107,10 @@ defmodule Sentry.Opentelemetry.SpanProcessor do %SpanRecord{attributes: attributes, origin: "opentelemetry_bandit"} = root_span, child_spans ) do - %Sentry.Transaction{ + Transaction.new(%{ start_timestamp: root_span.start_time, timestamp: root_span.end_time, - transaction: attributes[:"http.target"], + transaction: attributes["http.target"], transaction_info: %{ source: "url" }, @@ -132,19 +118,19 @@ defmodule Sentry.Opentelemetry.SpanProcessor do trace: build_trace_context(root_span) }, request: %{ - url: attributes[:"http.url"], - method: attributes[:"http.method"], + url: attributes["http.url"], + method: attributes["http.method"], headers: %{ - "User-Agent" => attributes[:"http.user_agent"] + "User-Agent" => attributes["http.user_agent"] }, env: %{ - "SERVER_NAME" => attributes[:"net.peer.name"], - "SERVER_PORT" => attributes[:"net.peer.port"] + "SERVER_NAME" => attributes["net.peer.name"], + "SERVER_PORT" => attributes["net.peer.port"] } }, measurements: %{}, spans: Enum.map(child_spans, &build_span(&1)) - } + }) end defp build_trace_context( @@ -157,19 +143,21 @@ defmodule Sentry.Opentelemetry.SpanProcessor do op: "http.server", origin: root_span.origin, data: %{ - "http.response.status_code" => attributes[:"http.status_code"] + "http.response.status_code" => attributes["http.status_code"] } } end - defp build_trace_context(%SpanRecord{origin: "opentelemetry_ecto"} = root_span) do + defp build_trace_context( + %SpanRecord{origin: "opentelemetry_ecto", attributes: attributes} = root_span + ) do %{ trace_id: root_span.trace_id, span_id: root_span.span_id, parent_span_id: root_span.parent_span_id, - op: "db", + op: "db.#{attributes["db.type"]}.ecto", origin: root_span.origin, - data: root_span.attributes + data: attributes } end @@ -187,7 +175,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do defp build_span( %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record ) do - op = "#{attributes[:"phoenix.plug"]}##{attributes[:"phoenix.action"]}" + op = "#{attributes["phoenix.plug"]}##{attributes["phoenix.action"]}" %Span{ op: op, @@ -196,7 +184,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do trace_id: span_record.trace_id, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id, - description: attributes[:"http.route"], + description: attributes["http.route"], origin: span_record.origin } end @@ -228,16 +216,14 @@ defmodule Sentry.Opentelemetry.SpanProcessor do defp build_span(%SpanRecord{origin: "opentelemetry_ecto", attributes: attributes} = span_record) do %Span{ trace_id: span_record.trace_id, - op: span_record.name, - start_timestamp: span_record.start_time, - timestamp: span_record.end_time, span_id: span_record.span_id, parent_span_id: span_record.parent_span_id, + op: "db.#{attributes["db.type"]}.ecto", + description: attributes["db.statement"] || span_record.name, origin: span_record.origin, - data: %{ - "db.system" => attributes[:"db.system"], - "db.name" => attributes[:"db.name"] - } + start_timestamp: span_record.start_time, + timestamp: span_record.end_time, + data: attributes } end diff --git a/lib/sentry/opentelemetry/span_record.ex b/lib/sentry/opentelemetry/span_record.ex index 29d5dabd..63b05b68 100644 --- a/lib/sentry/opentelemetry/span_record.ex +++ b/lib/sentry/opentelemetry/span_record.ex @@ -30,13 +30,20 @@ defmodule Sentry.Opentelemetry.SpanRecord do origin: origin, start_time: cast_timestamp(otel_attrs[:start_time]), end_time: cast_timestamp(otel_attrs[:end_time]), - attributes: attributes + 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) diff --git a/lib/sentry/transaction.ex b/lib/sentry/transaction.ex index 16265873..3af249b2 100644 --- a/lib/sentry/transaction.ex +++ b/lib/sentry/transaction.ex @@ -10,8 +10,6 @@ defmodule Sentry.Transaction do :transaction, :transaction_info, :contexts, - :platform, - :sdk, :request, :measurements, spans: [], diff --git a/test_integrations/phoenix_app/lib/phoenix_app/application.ex b/test_integrations/phoenix_app/lib/phoenix_app/application.ex index e6a90df9..e568512f 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app/application.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app/application.ex @@ -15,7 +15,7 @@ defmodule PhoenixApp.Application do OpentelemetryBandit.setup() OpentelemetryPhoenix.setup() - OpentelemetryEcto.setup([:phoenix_app, :repo]) + OpentelemetryEcto.setup([:phoenix_app, :repo], db_statement: :enabled) children = [ PhoenixAppWeb.Telemetry, diff --git a/test_integrations/phoenix_app/test/phoenix_app/repo_test.ex b/test_integrations/phoenix_app/test/phoenix_app/repo_test.ex new file mode 100644 index 00000000..eb6e597c --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app/repo_test.ex @@ -0,0 +1,27 @@ +defmodule PhoenixApp.RepoTest do + use PhoenixApp.DataCase + + alias PhoenixApp.{Repo, User} + + import Sentry.TestHelpers + + setup do + put_test_config(dsn: "http://public:secret@localhost:8080/1") + + 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: "db"} + assert transaction.contexts.trace.op == "db.sql.ecto" + assert transaction.contexts.trace.data["db.system"] == :sqlite + end +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 index 31460ddf..3d94d83a 100644 --- 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 @@ -60,7 +60,8 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do assert [span] = transaction.spans - assert span.op == "phoenix_app.repo.query:users" + assert span.op == "db.sql.ecto" + assert String.starts_with?(span.description, "SELECT ") assert span.trace_id == trace.trace_id assert span.parent_span_id == trace.span_id end From b681c9a8002481496d9c4474338dc3949d9a24c5 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 09:51:39 +0000 Subject: [PATCH 15/39] Unify top-level ecto transactions and ecto spans --- lib/sentry/opentelemetry/span_processor.ex | 3 ++- .../test/phoenix_app/{repo_test.ex => repo_test.exs} | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) rename test_integrations/phoenix_app/test/phoenix_app/{repo_test.ex => repo_test.exs} (75%) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 539ad729..7954a573 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -53,7 +53,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do start_timestamp: root_span.start_time, timestamp: root_span.end_time, transaction_info: %{ - source: "db" + source: "component" }, contexts: %{ trace: build_trace_context(root_span) @@ -156,6 +156,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do span_id: root_span.span_id, parent_span_id: root_span.parent_span_id, op: "db.#{attributes["db.type"]}.ecto", + description: attributes["db.statement"] || root_span.name, origin: root_span.origin, data: attributes } diff --git a/test_integrations/phoenix_app/test/phoenix_app/repo_test.ex b/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs similarity index 75% rename from test_integrations/phoenix_app/test/phoenix_app/repo_test.ex rename to test_integrations/phoenix_app/test/phoenix_app/repo_test.exs index eb6e597c..b9ad2a35 100644 --- a/test_integrations/phoenix_app/test/phoenix_app/repo_test.ex +++ b/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs @@ -1,7 +1,7 @@ defmodule PhoenixApp.RepoTest do use PhoenixApp.DataCase - alias PhoenixApp.{Repo, User} + alias PhoenixApp.{Repo, Accounts.User} import Sentry.TestHelpers @@ -20,8 +20,9 @@ defmodule PhoenixApp.RepoTest do assert [transaction] = transactions - assert transaction.transaction_info == %{source: "db"} + assert transaction.transaction_info == %{source: "component"} assert transaction.contexts.trace.op == "db.sql.ecto" + assert String.starts_with?(transaction.contexts.trace.description, "SELECT") assert transaction.contexts.trace.data["db.system"] == :sqlite end end From 61baf6a5ab715174fedda203c4f75984cfb62de7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 10:29:50 +0000 Subject: [PATCH 16/39] Return full URL and status info in Phoenix transactions --- lib/sentry/opentelemetry/span_processor.ex | 41 +++++++++++++++++-- lib/sentry/transaction.ex | 1 + .../lib/phoenix_app/application.ex | 2 +- .../controllers/transaction_test.exs | 6 ++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 7954a573..fcdae8b2 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -79,7 +79,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do trace: build_trace_context(root_span) }, request: %{ - url: attributes["http.target"], + url: url_from_attributes(attributes), method: attributes["http.method"], headers: %{ "User-Agent" => attributes["http.user_agent"] @@ -142,6 +142,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do parent_span_id: nil, op: "http.server", origin: root_span.origin, + status: status_from_attributes(attributes), data: %{ "http.response.status_code" => attributes["http.status_code"] } @@ -176,10 +177,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do defp build_span( %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record ) do - op = "#{attributes["phoenix.plug"]}##{attributes["phoenix.action"]}" - %Span{ - op: op, + op: "#{attributes["phoenix.plug"]}##{attributes["phoenix.action"]}", start_timestamp: span_record.start_time, timestamp: span_record.end_time, trace_id: span_record.trace_id, @@ -238,4 +237,38 @@ defmodule Sentry.Opentelemetry.SpanProcessor do parent_span_id: span_record.parent_span_id } end + + defp url_from_attributes(attributes) do + URI.to_string(%URI{ + scheme: attributes["http.scheme"], + host: attributes["net.host.name"], + port: attributes["net.host.port"], + path: attributes["http.target"] + }) + end + + defp status_from_attributes(%{"http.status_code" => status_code}) do + cond do + status_code in 200..299 -> + "ok" + + status_code in [400, 401, 403, 404, 409, 429, 499, 500, 501, 503, 504] -> + %{ + 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" + }[status_code] + + true -> + "unknown_error" + end + end end diff --git a/lib/sentry/transaction.ex b/lib/sentry/transaction.ex index 3af249b2..3d31239e 100644 --- a/lib/sentry/transaction.ex +++ b/lib/sentry/transaction.ex @@ -9,6 +9,7 @@ defmodule Sentry.Transaction do :timestamp, :transaction, :transaction_info, + :status, :contexts, :request, :measurements, diff --git a/test_integrations/phoenix_app/lib/phoenix_app/application.ex b/test_integrations/phoenix_app/lib/phoenix_app/application.ex index e568512f..ee64ab13 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app/application.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app/application.ex @@ -13,7 +13,7 @@ defmodule PhoenixApp.Application do config: %{metadata: [:file, :line]} }) - OpentelemetryBandit.setup() + # OpentelemetryBandit.setup() OpentelemetryPhoenix.setup() OpentelemetryEcto.setup([:phoenix_app, :repo], db_statement: :enabled) 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 index 3d94d83a..f0cc98d5 100644 --- 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 @@ -25,9 +25,10 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do assert trace.origin == "opentelemetry_phoenix" assert trace.op == "http.server" assert trace.data == %{"http.response.status_code" => 200} + assert trace.status == "ok" assert transaction.request.env == %{"SERVER_NAME" => "www.example.com", "SERVER_PORT" => 80} - assert transaction.request.url == "/transaction" + assert transaction.request.url == "http://www.example.com/transaction" assert transaction.request.method == "GET" assert [span] = transaction.spans @@ -53,9 +54,10 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do assert trace.origin == "opentelemetry_phoenix" assert trace.op == "http.server" assert trace.data == %{"http.response.status_code" => 200} + assert trace.status == "ok" assert transaction.request.env == %{"SERVER_NAME" => "www.example.com", "SERVER_PORT" => 80} - assert transaction.request.url == "/users" + assert transaction.request.url == "http://www.example.com/users" assert transaction.request.method == "GET" assert [span] = transaction.spans From e47521ab2f00797d65326a3bcf1704339ccb52c8 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 25 Sep 2024 10:39:20 +0000 Subject: [PATCH 17/39] Fix extracting span record when sentry is an external dep --- lib/sentry/opentelemetry/span_record.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sentry/opentelemetry/span_record.ex b/lib/sentry/opentelemetry/span_record.ex index 63b05b68..dc5c4cd4 100644 --- a/lib/sentry/opentelemetry/span_record.ex +++ b/lib/sentry/opentelemetry/span_record.ex @@ -1,7 +1,7 @@ defmodule Sentry.Opentelemetry.SpanRecord do require Record - @fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl") + @fields Record.extract(:span, from_lib: "opentelemetry/include/otel_span.hrl") Record.defrecordp(:span, @fields) defstruct @fields ++ [:origin] From ca1606fed9bef49b2a9914719b091d3e1791a0aa Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 26 Sep 2024 17:02:39 +0000 Subject: [PATCH 18/39] Support for liveview traces/spans --- lib/sentry/opentelemetry/span_processor.ex | 55 ++++++- test_integrations/phoenix_app/config/test.exs | 1 + test_integrations/phoenix_app/db/dev.sqlite3 | Bin 16384 -> 0 bytes .../phoenix_app/db/dev.sqlite3-shm | Bin 32768 -> 0 bytes .../phoenix_app/db/dev.sqlite3-wal | 0 test_integrations/phoenix_app/db/test.sqlite3 | Bin 4096 -> 0 bytes .../phoenix_app/db/test.sqlite3-shm | Bin 32768 -> 0 bytes .../phoenix_app/db/test.sqlite3-wal | Bin 24752 -> 0 bytes .../phoenix_app/lib/phoenix_app/accounts.ex | 104 +++++++++++++ .../lib/phoenix_app/accounts/user.ex | 18 +++ .../phoenix_app/lib/phoenix_app/user.ex | 9 -- .../live/user_live/form_component.ex | 83 +++++++++++ .../phoenix_app_web/live/user_live/index.ex | 47 ++++++ .../live/user_live/index.html.heex | 42 ++++++ .../phoenix_app_web/live/user_live/show.ex | 21 +++ .../live/user_live/show.html.heex | 27 ++++ .../phoenix_app/lib/phoenix_app_web/router.ex | 8 +- test_integrations/phoenix_app/mix.exs | 1 + test_integrations/phoenix_app/mix.lock | 1 + ...rs.exs => 20240926155911_create_users.exs} | 3 +- .../controllers/transaction_test.exs | 13 +- .../phoenix_app_web/live/user_live_test.exs | 139 ++++++++++++++++++ .../phoenix_app/test/support/data_case.ex | 26 ++-- .../support/fixtures/accounts_fixtures.ex | 21 +++ .../phoenix_app/test/test_helper.exs | 2 +- 25 files changed, 588 insertions(+), 33 deletions(-) delete mode 100644 test_integrations/phoenix_app/db/dev.sqlite3 delete mode 100644 test_integrations/phoenix_app/db/dev.sqlite3-shm delete mode 100644 test_integrations/phoenix_app/db/dev.sqlite3-wal delete mode 100644 test_integrations/phoenix_app/db/test.sqlite3 delete mode 100644 test_integrations/phoenix_app/db/test.sqlite3-shm delete mode 100644 test_integrations/phoenix_app/db/test.sqlite3-wal create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/accounts.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/accounts/user.ex delete mode 100644 test_integrations/phoenix_app/lib/phoenix_app/user.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/form_component.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/index.html.heex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/user_live/show.html.heex rename test_integrations/phoenix_app/priv/repo/migrations/{20240924163953_create_users.exs => 20240926155911_create_users.exs} (72%) create mode 100644 test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs create mode 100644 test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index fcdae8b2..0b65dcbe 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -67,7 +67,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do defp build_transaction( %SpanRecord{attributes: attributes, origin: "opentelemetry_phoenix"} = root_span, child_spans - ) do + ) + when map_size(attributes) > 0 do Transaction.new(%{ transaction: "#{attributes["phoenix.plug"]}##{attributes["phoenix.action"]}", start_timestamp: root_span.start_time, @@ -103,6 +104,25 @@ defmodule Sentry.Opentelemetry.SpanProcessor do }) end + defp build_transaction( + %SpanRecord{attributes: attributes, origin: "opentelemetry_phoenix"} = root_span, + child_spans + ) + when map_size(attributes) == 0 do + Transaction.new(%{ + transaction: root_span.name, + start_timestamp: root_span.start_time, + timestamp: root_span.end_time, + transaction_info: %{ + source: "view" + }, + contexts: %{ + trace: build_trace_context(root_span) + }, + spans: Enum.map(child_spans, &build_span(&1)) + }) + end + defp build_transaction( %SpanRecord{attributes: attributes, origin: "opentelemetry_bandit"} = root_span, child_spans @@ -135,7 +155,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do defp build_trace_context( %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = root_span - ) do + ) + when map_size(attributes) > 0 do %{ trace_id: root_span.trace_id, span_id: root_span.span_id, @@ -149,6 +170,20 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end + defp build_trace_context( + %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = root_span + ) + when map_size(attributes) == 0 do + %{ + trace_id: root_span.trace_id, + span_id: root_span.span_id, + parent_span_id: nil, + op: "http.server.live", + description: root_span.name, + origin: root_span.origin + } + end + defp build_trace_context( %SpanRecord{origin: "opentelemetry_ecto", attributes: attributes} = root_span ) do @@ -174,6 +209,22 @@ defmodule Sentry.Opentelemetry.SpanProcessor do } end + defp build_span( + %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record + ) + when map_size(attributes) == 0 do + %Span{ + op: "http.server.live", + description: span_record.name, + 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 + } + end + defp build_span( %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = span_record ) do diff --git a/test_integrations/phoenix_app/config/test.exs b/test_integrations/phoenix_app/config/test.exs index 4eab4114..c7c89488 100644 --- a/test_integrations/phoenix_app/config/test.exs +++ b/test_integrations/phoenix_app/config/test.exs @@ -3,6 +3,7 @@ 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, diff --git a/test_integrations/phoenix_app/db/dev.sqlite3 b/test_integrations/phoenix_app/db/dev.sqlite3 deleted file mode 100644 index 1408c12ffee85d8a4d4f9304fc080e58cc549180..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI%Pfvni90%|RaCu7G+G6YUjFV1HLu$)Y)L3&N8sU~FM@q#e7=j(V*}C~gdNaL* zw$=-2J!l>z*S-2a1D-v9d43;Gzj1k?yOBe0h9l38Xq9Xco+rnY5<+-(r`WwL$u_qf z_@U;>-Xz#$6NJo+WG9(dR-r%u0uX=z1Rwwb2tWV=5P-n{7HA0@VxhowBKvyagu#I2 zrQOg8#*W`hHdCjjrdk@c)DvB!iMcu7_M9SHnV;_RX;Bop$L;x^F^Bd6cBqeUMz znie(My3Pj2_kDZqSbi#SX|Y`9rU#3Fdv}g!cRlxZWJm7M55r^!zk29z<8c5FEJC(; zZ4~Q{nvZjHF8fZ{g#rNxKmY;|fB*y_009U<00Izzz^@h9d3u>WzkLJ(smc{uDOHY2 zvSRHY))b{CSK|Di{q#qH00bZa0SG_<0uX=z1Rwwb2teQu3n2gh!ygx+K>z{}fB*y_ R009U<00Izz00h1SJ^|Lpi>Ckp diff --git a/test_integrations/phoenix_app/db/dev.sqlite3-shm b/test_integrations/phoenix_app/db/dev.sqlite3-shm deleted file mode 100644 index fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeIuAr62r37U}9o$P*7lCU|@t|AVoG{WYDWBLM0&p diff --git a/test_integrations/phoenix_app/db/test.sqlite3-wal b/test_integrations/phoenix_app/db/test.sqlite3-wal deleted file mode 100644 index 0793dd61235d2ee9761a6779fd4339b419349116..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24752 zcmeI)!E4h{90%~1q;v>w6U0Tu!y_uJbak_96@`MG1gUoEtQlGmN?e~BXgk}a3cE;$ z2W8;jFgy(p11}y#7>XVz2;xo99{dYr2M_9d=~_}(q(@7>k2G!G>o09S=_T*?OV2(G zuaa2nFd+wsX!7gdi4RXx&-Z=bI#k|&@-gp{{Qci{wsJpO&x~tVv$o6V?d5vKa_KCO zmSu8*Qn8EOhX@l*#D~0B?UlDb-*&&3GC8$U8|5R?_zK|*8U!E!0SG_<0uX=z1Rwwb z2tXiU0!>94QKzS+X4188m6@}2msPA{#lBOwTzk3d?CFp%=Jm9xQ!_o6)v2~;T%(iP zeO7mPutqb6sbA9bbRnOaPv;Btie8wanq75R-DRbs+7+z6={L{!uTWa2
rXKJ@J~?C(`Y9 zsc)v8rWegz#^6Eox?%FXyZD55!o6P-o*Frm8jIO1A8gz!S^fllcP#Q>)eCg*FL?Df z`eyU^=z6=xzeUDd!TwR==pg_B2tWV=5P$##AOHafKmY>6EimzDdu`+EubM(;<1_I@ zZ01}no-j|JIiF1Mg&&*9|99sFjuGSf#QLWPxQ}4C&pN(61Rwwb2tWV=5P$##AOHaf yKwv-taSuSK^Zx=TlAlxT-R5P?3k;}-w?O~`5P$##AOHafKmY;|fB*!BSl|yAHTw(z 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/user.ex b/test_integrations/phoenix_app/lib/phoenix_app/user.ex deleted file mode 100644 index 728d982d..00000000 --- a/test_integrations/phoenix_app/lib/phoenix_app/user.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixApp.User do - use Ecto.Schema - - schema "users" do - field :name, :string - - timestamps() - end -end 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 6bb9caad..c1bac216 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex @@ -20,7 +20,13 @@ defmodule PhoenixAppWeb.Router do get "/", PageController, :home get "/exception", PageController, :exception get "/transaction", PageController, :transaction - get "/users", PageController, :users + + 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 1cb8a9c3..c5756d3c 100644 --- a/test_integrations/phoenix_app/mix.exs +++ b/test_integrations/phoenix_app/mix.exs @@ -44,6 +44,7 @@ defmodule PhoenixApp.MixProject do {: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", diff --git a/test_integrations/phoenix_app/mix.lock b/test_integrations/phoenix_app/mix.lock index 650820c0..beb072c7 100644 --- a/test_integrations/phoenix_app/mix.lock +++ b/test_integrations/phoenix_app/mix.lock @@ -43,6 +43,7 @@ "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"}, "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.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [: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", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, diff --git a/test_integrations/phoenix_app/priv/repo/migrations/20240924163953_create_users.exs b/test_integrations/phoenix_app/priv/repo/migrations/20240926155911_create_users.exs similarity index 72% rename from test_integrations/phoenix_app/priv/repo/migrations/20240924163953_create_users.exs rename to test_integrations/phoenix_app/priv/repo/migrations/20240926155911_create_users.exs index 5ac933c0..21f4a335 100644 --- a/test_integrations/phoenix_app/priv/repo/migrations/20240924163953_create_users.exs +++ b/test_integrations/phoenix_app/priv/repo/migrations/20240926155911_create_users.exs @@ -4,8 +4,9 @@ defmodule PhoenixApp.Repo.Migrations.CreateUsers do def change do create table(:users) do add :name, :string + add :age, :integer - timestamps() + timestamps(type: :utc_datetime) end end 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 index f0cc98d5..5d90a0fc 100644 --- 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 @@ -47,7 +47,7 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do assert [transaction] = transactions - assert transaction.transaction == "Elixir.PhoenixAppWeb.PageController#users" + assert transaction.transaction == "Elixir.Phoenix.LiveView.Plug#index" assert transaction.transaction_info == %{source: "view"} trace = transaction.contexts.trace @@ -60,11 +60,12 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do assert transaction.request.url == "http://www.example.com/users" assert transaction.request.method == "GET" - assert [span] = transaction.spans + assert [span_mount, span_handle_params] = transaction.spans - assert span.op == "db.sql.ecto" - assert String.starts_with?(span.description, "SELECT ") - assert span.trace_id == trace.trace_id - assert span.parent_span_id == trace.span_id + assert span_mount.op == "http.server.live" + assert span_mount.description == "PhoenixAppWeb.UserLive.Index.mount" + + assert span_handle_params.op == "http.server.live" + assert span_handle_params.description == "PhoenixAppWeb.UserLive.Index.handle_params" 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..f1ad06b2 --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs @@ -0,0 +1,139 @@ +defmodule PhoenixAppWeb.UserLiveTest do + use PhoenixAppWeb.ConnCase + + 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") + + 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 == "view" + assert transaction_save.contexts.trace.op == "http.server.live" + assert transaction_save.contexts.trace.origin == "opentelemetry_phoenix" + + assert length(transaction_save.spans) == 1 + span = List.first(transaction_save.spans) + assert span.op == "db.sql.ecto" + assert span.description =~ "INSERT INTO \"users\"" + assert span.data["db.system"] == :sqlite + assert span.data["db.type"] == :sql + assert span.origin == "opentelemetry_ecto" + end + + test "updates user in listing", %{conn: conn, user: user} do + {:ok, index_live, _html} = live(conn, ~p"/users") + + assert index_live |> element("#users-#{user.id} a", "Edit") |> render_click() =~ + "Edit User" + + assert_patch(index_live, ~p"/users/#{user}/edit") + + assert index_live + |> form("#user-form", user: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#user-form", user: @update_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/users") + + html = render(index_live) + assert html =~ "User updated successfully" + assert html =~ "some updated name" + end + + test "deletes user in listing", %{conn: conn, user: user} do + {:ok, index_live, _html} = live(conn, ~p"/users") + + assert index_live |> element("#users-#{user.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#users-#{user.id}") + end + end + + describe "Show" do + setup [:create_user] + + test "displays user", %{conn: conn, user: user} do + {:ok, _show_live, html} = live(conn, ~p"/users/#{user}") + + assert html =~ "Show User" + assert html =~ user.name + end + + test "updates user within modal", %{conn: conn, user: user} do + {:ok, show_live, _html} = live(conn, ~p"/users/#{user}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit User" + + assert_patch(show_live, ~p"/users/#{user}/show/edit") + + assert show_live + |> form("#user-form", user: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert show_live + |> form("#user-form", user: @update_attrs) + |> render_submit() + + assert_patch(show_live, ~p"/users/#{user}") + + html = render(show_live) + assert html =~ "User updated successfully" + assert html =~ "some updated name" + end + end +end diff --git a/test_integrations/phoenix_app/test/support/data_case.ex b/test_integrations/phoenix_app/test/support/data_case.ex index 648de1de..d58f0fe0 100644 --- a/test_integrations/phoenix_app/test/support/data_case.ex +++ b/test_integrations/phoenix_app/test/support/data_case.ex @@ -20,9 +20,9 @@ defmodule PhoenixApp.DataCase do quote do alias PhoenixApp.Repo - # import Ecto - # import Ecto.Changeset - # import Ecto.Query + import Ecto + import Ecto.Changeset + import Ecto.Query import PhoenixApp.DataCase end end @@ -35,9 +35,9 @@ defmodule PhoenixApp.DataCase do @doc """ Sets up the sandbox based on the test tags. """ - def setup_sandbox(_tags) do - # pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PhoenixApp.Repo, shared: not tags[:async]) - # on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PhoenixApp.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) end @doc """ @@ -48,11 +48,11 @@ defmodule PhoenixApp.DataCase do assert %{password: ["password is too short"]} = errors_on(changeset) """ - # def errors_on(changeset) do - # Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> - # Regex.replace(~r"%{(\w+)}", message, fn _, key -> - # opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - # end) - # end) - # end + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end end diff --git a/test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex b/test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 00000000..eb0799e2 --- /dev/null +++ b/test_integrations/phoenix_app/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,21 @@ +defmodule PhoenixApp.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `PhoenixApp.Accounts` context. + """ + + @doc """ + Generate a user. + """ + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> Enum.into(%{ + age: 42, + name: "some name" + }) + |> PhoenixApp.Accounts.create_user() + + user + end +end diff --git a/test_integrations/phoenix_app/test/test_helper.exs b/test_integrations/phoenix_app/test/test_helper.exs index 97b7531c..8b917f93 100644 --- a/test_integrations/phoenix_app/test/test_helper.exs +++ b/test_integrations/phoenix_app/test/test_helper.exs @@ -1,2 +1,2 @@ ExUnit.start() -# Ecto.Adapters.SQL.Sandbox.mode(PhoenixApp.Repo, :manual) +Ecto.Adapters.SQL.Sandbox.mode(PhoenixApp.Repo, :manual) From f0cdc22c42ed717731bf1e79cce7ee6f9150cd25 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 26 Sep 2024 20:07:10 +0000 Subject: [PATCH 19/39] Remove span records after sending a transaction --- lib/sentry/opentelemetry/span_processor.ex | 3 +++ lib/sentry/opentelemetry/span_storage.ex | 22 +++++++++++++++++++ .../opentelemetry/span_processor_test.exs | 16 ++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 0b65dcbe..ab42728d 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -25,6 +25,9 @@ defmodule Sentry.Opentelemetry.SpanProcessor do transaction = build_transaction(root_span, child_spans) Sentry.send_transaction(transaction) + + SpanStorage.remove_span(span_record.span_id) + SpanStorage.remove_child_spans(span_record.span_id) end :ok diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index f38cd9ad..6bdfc426 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -25,6 +25,14 @@ defmodule Sentry.Opentelemetry.SpanStorage do GenServer.call(__MODULE__, {:update_span, span_data}) end + def remove_span(span_id) do + GenServer.call(__MODULE__, {:remove_span, span_id}) + end + + def remove_child_spans(parent_span_id) do + GenServer.call(__MODULE__, {:remove_child_spans, parent_span_id}) + end + def handle_call({:store_span, span_data}, _from, state) do if span_data.parent_span_id == nil do new_state = put_in(state, [:root_spans, span_data.span_id], span_data) @@ -62,4 +70,18 @@ defmodule Sentry.Opentelemetry.SpanStorage do {:reply, :ok, new_state} end end + + def handle_call({:remove_span, span_id}, _from, state) do + new_state = %{ + state | + root_spans: Map.delete(state.root_spans, span_id), + child_spans: Map.delete(state.child_spans, span_id) + } + {:reply, :ok, new_state} + end + + def handle_call({:remove_child_spans, parent_span_id}, _from, state) do + new_state = %{state | child_spans: Map.delete(state.child_spans, parent_span_id)} + {:reply, :ok, new_state} + end end diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index 071b4962..a0c642f1 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -80,6 +80,22 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do assert_valid_trace_id(child_span_two.trace_id) end + test "removes span records from storage after sending a transaction" do + put_test_config(environment_name: "test") + + Sentry.Test.start_collecting_sentry_reports() + + TestEndpoint.instrumented_function() + + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + assert nil == + Sentry.Opentelemetry.SpanStorage.get_root_span(transaction.contexts.trace.span_id) + + assert [] == + Sentry.Opentelemetry.SpanStorage.get_child_spans(transaction.contexts.trace.span_id) + end + defp assert_valid_iso8601(timestamp) do case DateTime.from_iso8601(timestamp) do {:ok, datetime, _offset} -> From faf90708899f7cb31cf5e43deeed9b7621689977 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 26 Sep 2024 20:14:47 +0000 Subject: [PATCH 20/39] Fix formatting --- .../controllers/page_controller.ex | 2 +- test_integrations/phoenix_app/mix.exs | 15 ++++++--------- .../test/phoenix_app_web/live/user_live_test.exs | 7 ++++--- 3 files changed, 11 insertions(+), 13 deletions(-) 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 177fd1f6..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 @@ -14,7 +14,7 @@ defmodule PhoenixAppWeb.PageController do end def transaction(conn, _params) do - Tracer.with_span("test_span") do + Tracer.with_span "test_span" do :timer.sleep(100) end diff --git a/test_integrations/phoenix_app/mix.exs b/test_integrations/phoenix_app/mix.exs index c5756d3c..3bfa5fd1 100644 --- a/test_integrations/phoenix_app/mix.exs +++ b/test_integrations/phoenix_app/mix.exs @@ -39,19 +39,18 @@ defmodule PhoenixApp.MixProject do {: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}, + 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}, @@ -65,13 +64,11 @@ defmodule PhoenixApp.MixProject do {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, {:bypass, "~> 2.1", only: :test}, - {:opentelemetry, "~> 1.4"}, {:opentelemetry_api, "~> 1.3"}, {:opentelemetry_phoenix, "~> 1.2"}, {:opentelemetry_bandit, "~> 0.1.4", github: "solnic/opentelemetry-bandit"}, {:opentelemetry_ecto, "~> 1.2"}, - {:sentry, path: "../.."}, {:hackney, "~> 1.18"} ] 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 index f1ad06b2..c9d00f17 100644 --- 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 @@ -54,9 +54,10 @@ defmodule PhoenixAppWeb.UserLiveTest do transactions = Sentry.Test.pop_sentry_transactions() - transaction_save = Enum.find(transactions, fn transaction -> - transaction.transaction == "PhoenixAppWeb.UserLive.Index.handle_event#save" - end) + 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 == "view" From edcc9a3cc90847f01ba3ba3814bdc4907bb18434 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 26 Sep 2024 20:24:44 +0000 Subject: [PATCH 21/39] Fix formatting --- lib/sentry/opentelemetry/span_storage.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index 6bdfc426..73a0bea8 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -73,10 +73,11 @@ defmodule Sentry.Opentelemetry.SpanStorage do def handle_call({:remove_span, span_id}, _from, state) do new_state = %{ - state | - root_spans: Map.delete(state.root_spans, span_id), - child_spans: Map.delete(state.child_spans, span_id) + state + | root_spans: Map.delete(state.root_spans, span_id), + child_spans: Map.delete(state.child_spans, span_id) } + {:reply, :ok, new_state} end From 7e65e12cf38462fbbf75e8a812c3080e63d01834 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 27 Sep 2024 09:00:46 +0000 Subject: [PATCH 22/39] Fix dialyzer warnings --- lib/sentry/client.ex | 7 +-- lib/sentry/opentelemetry/span_record.ex | 3 +- lib/sentry/transport/sender.ex | 64 +++---------------------- lib/sentry/transport/sender_pool.ex | 6 +++ 4 files changed, 17 insertions(+), 63 deletions(-) diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 995d8349..2da5040b 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -123,11 +123,8 @@ defmodule Sentry.Client do {:ok, id} -> {:ok, id} - {:error, {status, headers, body}} -> - {:error, ClientError.server_error(status, headers, body)} - - {:error, reason} -> - {:error, ClientError.new(reason)} + {:error, %ClientError{} = error} -> + {:error, error} end end diff --git a/lib/sentry/opentelemetry/span_record.ex b/lib/sentry/opentelemetry/span_record.ex index dc5c4cd4..d314e3b4 100644 --- a/lib/sentry/opentelemetry/span_record.ex +++ b/lib/sentry/opentelemetry/span_record.ex @@ -1,5 +1,6 @@ defmodule Sentry.Opentelemetry.SpanRecord do require Record + require OpenTelemetry @fields Record.extract(:span, from_lib: "opentelemetry/include/otel_span.hrl") Record.defrecordp(:span, @fields) @@ -54,7 +55,7 @@ defmodule Sentry.Opentelemetry.SpanRecord do defp cast_timestamp(nil), do: nil defp cast_timestamp(timestamp) do - nano_timestamp = :opentelemetry.timestamp_to_nano(timestamp) + nano_timestamp = OpenTelemetry.timestamp_to_nano(timestamp) {:ok, datetime} = DateTime.from_unix(div(nano_timestamp, 1_000_000), :millisecond) DateTime.to_iso8601(datetime) diff --git a/lib/sentry/transport/sender.ex b/lib/sentry/transport/sender.ex index 923a7a63..72ce407f 100644 --- a/lib/sentry/transport/sender.ex +++ b/lib/sentry/transport/sender.ex @@ -3,7 +3,7 @@ defmodule Sentry.Transport.Sender do use GenServer - alias Sentry.{Envelope, Event, Transport, Transaction, LoggerUtils} + alias Sentry.{Envelope, Event, Transport, Transaction} require Logger @@ -61,64 +61,14 @@ defmodule Sentry.Transport.Sender do @impl GenServer def handle_cast({:send, client, %Transaction{} = transaction}, %__MODULE__{} = state) do - envelope = Envelope.from_transaction(transaction) - - envelope - |> Transport.encode_and_post_envelope(client) - |> maybe_log_send_result([transaction]) + _ = + transaction + |> Envelope.from_transaction() + |> Transport.encode_and_post_envelope(client) - # We sent an event, so we can decrease the number of queued events. - Transport.SenderPool.decrease_queued_events_counter() + # We sent a transaction, so we can decrease the number of queued transactions. + Transport.SenderPool.decrease_queued_transactions_counter() {:noreply, state} end - - ## Helpers - - defp maybe_log_send_result(send_result, events) do - if Enum.any?(events, fn item -> - case item do - %Event{} -> item.source == :logger - _ -> false - end - end) do - :ok - else - message = - case send_result do - {:error, {:invalid_json, error}} -> - "Unable to encode JSON Sentry error - #{inspect(error)}" - - {:error, {:request_failure, last_error}} -> - case last_error do - {kind, data, stacktrace} - when kind in [:exit, :throw, :error] and is_list(stacktrace) -> - Exception.format(kind, data, stacktrace) - - _other -> - "Error in HTTP Request to Sentry - #{inspect(last_error)}" - end - - {:error, http_reponse} -> - {status, headers, _body} = http_reponse - - error_header = - :proplists.get_value("X-Sentry-Error", headers, nil) || - :proplists.get_value("x-sentry-error", headers, nil) || "" - - if error_header != "" do - "Received #{status} from Sentry server: #{error_header}" - else - "Received #{status} from Sentry server" - end - - result -> - result - end - - if message do - LoggerUtils.log(fn -> ["Failed to send Sentry event. ", message] end) - end - end - end end diff --git a/lib/sentry/transport/sender_pool.ex b/lib/sentry/transport/sender_pool.ex index b247441a..e620cb6c 100644 --- a/lib/sentry/transport/sender_pool.ex +++ b/lib/sentry/transport/sender_pool.ex @@ -58,6 +58,12 @@ defmodule Sentry.Transport.SenderPool do :counters.sub(counter, 1, 1) end + @spec decrease_queued_transactions_counter() :: :ok + def decrease_queued_transactions_counter do + counter = :persistent_term.get(@queued_transactions_key) + :counters.sub(counter, 1, 1) + end + @spec get_queued_events_counter() :: non_neg_integer() def get_queued_events_counter do counter = :persistent_term.get(@queued_events_key) From b64aff5469f1308e5e77eb1f9a53ea37e6c6c48f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 27 Sep 2024 09:21:23 +0000 Subject: [PATCH 23/39] More dialyzer fixes --- lib/sentry/envelope.ex | 4 +++- lib/sentry/opentelemetry/span_processor.ex | 21 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/sentry/envelope.ex b/lib/sentry/envelope.ex index b29ce9c4..872fd177 100644 --- a/lib/sentry/envelope.ex +++ b/lib/sentry/envelope.ex @@ -6,7 +6,9 @@ defmodule Sentry.Envelope do @type t() :: %__MODULE__{ event_id: UUID.t(), - items: [Event.t() | Attachment.t() | CheckIn.t() | ClientReport.t(), ...] + items: [ + Event.t() | Attachment.t() | CheckIn.t() | ClientReport.t() | Transaction.t() + ] } @enforce_keys [:event_id, :items] diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index ab42728d..39ec6845 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -1,6 +1,8 @@ defmodule Sentry.Opentelemetry.SpanProcessor do @behaviour :otel_span_processor + require Logger + alias Sentry.{Span, Transaction, Opentelemetry.SpanStorage, Opentelemetry.SpanRecord} @impl true @@ -24,13 +26,26 @@ defmodule Sentry.Opentelemetry.SpanProcessor do transaction = build_transaction(root_span, child_spans) - Sentry.send_transaction(transaction) + result = + case Sentry.send_transaction(transaction) do + {:ok, _id} -> + true + + :ignored -> + true + + {:error, error} -> + Logger.error("Failed to send transaction to Sentry: #{inspect(error)}") + {:error, :invalid_span} + end SpanStorage.remove_span(span_record.span_id) SpanStorage.remove_child_spans(span_record.span_id) - end - :ok + result + else + true + end end @impl true From 3b4276a597015a9e0e67064b260281316eaf9f1b Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 23 Oct 2024 09:02:16 +0000 Subject: [PATCH 24/39] Fix build for 1.16 --- test_integrations/phoenix_app/mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_integrations/phoenix_app/mix.exs b/test_integrations/phoenix_app/mix.exs index 3bfa5fd1..c04f3959 100644 --- a/test_integrations/phoenix_app/mix.exs +++ b/test_integrations/phoenix_app/mix.exs @@ -67,7 +67,7 @@ defmodule PhoenixApp.MixProject do {:opentelemetry, "~> 1.4"}, {:opentelemetry_api, "~> 1.3"}, {:opentelemetry_phoenix, "~> 1.2"}, - {:opentelemetry_bandit, "~> 0.1.4", github: "solnic/opentelemetry-bandit"}, + # {:opentelemetry_bandit, "~> 0.1.4", github: "solnic/opentelemetry-bandit", depth: 1}, {:opentelemetry_ecto, "~> 1.2"}, {:sentry, path: "../.."}, {:hackney, "~> 1.18"} From d166232bc69df8b12127e51bf27fb27cbf5691e2 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 23 Oct 2024 09:19:41 +0000 Subject: [PATCH 25/39] Remove debugging statement --- lib/sentry/client.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 2da5040b..b6eb6b52 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -284,7 +284,6 @@ defmodule Sentry.Client do @spec render_transaction(%Transaction{}) :: map() def render_transaction(%Transaction{} = transaction) do - # IO.inspect(transaction, label: "transaction") transaction |> Transaction.to_map() |> Map.merge(%{ From fe785d2cedd5afb879984f8d7c738e7ca33c9d30 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 25 Oct 2024 15:27:02 +0200 Subject: [PATCH 26/39] Update lib/sentry/opentelemetry/span_processor.ex Co-authored-by: Andrea Leopardi --- lib/sentry/opentelemetry/span_processor.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 39ec6845..39a4949b 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -1,4 +1,5 @@ defmodule Sentry.Opentelemetry.SpanProcessor do + @moduledoc false @behaviour :otel_span_processor require Logger From 2b6a9d5babe16ee97dd00cec568e3c19e7b640f3 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 28 Oct 2024 09:27:57 +0000 Subject: [PATCH 27/39] WIP - rework SpanStorage to use ETS --- lib/sentry/application.ex | 4 +- lib/sentry/opentelemetry/span_processor.ex | 6 +- lib/sentry/opentelemetry/span_storage.ex | 112 ++++++++---------- .../opentelemetry/span_processor_test.exs | 20 ++++ 4 files changed, 78 insertions(+), 64 deletions(-) diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 7eb56571..49c51ef5 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -10,6 +10,9 @@ defmodule Sentry.Application do config = Config.validate!() :ok = Config.persist(config) + # Setup ETS tables for span storage + Sentry.Opentelemetry.SpanStorage.setup() + http_client = Keyword.fetch!(config, :client) maybe_http_client_spec = @@ -27,7 +30,6 @@ defmodule Sentry.Application do Sentry.Sources, Sentry.Dedupe, Sentry.ClientReport.Sender, - Sentry.Opentelemetry.SpanStorage, {Sentry.Integrations.CheckInIDMappings, [ max_expected_check_in_time: diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 39a4949b..11caa0d0 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -62,7 +62,7 @@ defmodule Sentry.Opentelemetry.SpanProcessor do contexts: %{ trace: build_trace_context(root_span) }, - spans: Enum.map([root_span | child_spans], &build_span(&1)) + spans: [build_span(root_span) | Enum.map(child_spans, &build_span(&1))] }) end @@ -304,7 +304,9 @@ defmodule Sentry.Opentelemetry.SpanProcessor do start_timestamp: span_record.start_time, timestamp: span_record.end_time, span_id: span_record.span_id, - parent_span_id: span_record.parent_span_id + parent_span_id: span_record.parent_span_id, + # Add origin to match other span types + origin: span_record.origin } end diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index 73a0bea8..fd49229a 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -1,88 +1,78 @@ defmodule Sentry.Opentelemetry.SpanStorage do - use GenServer + @moduledoc false - def start_link(_opts) do - GenServer.start_link(__MODULE__, nil, name: __MODULE__) - end - - def init(_) do - {:ok, %{root_spans: %{}, child_spans: %{}}} - end + @root_spans_table :sentry_root_spans + @child_spans_table :sentry_child_spans - def store_span(span_data) do - GenServer.call(__MODULE__, {:store_span, span_data}) - end + def setup do + case :ets.whereis(@root_spans_table) do + :undefined -> + :ets.new(@root_spans_table, [:set, :public, :named_table]) - def get_root_span(span_id) do - GenServer.call(__MODULE__, {:get_root_span, span_id}) - end + _ -> + :ok + end - def get_child_spans(parent_span_id) do - GenServer.call(__MODULE__, {:get_child_spans, parent_span_id}) - end + case :ets.whereis(@child_spans_table) do + :undefined -> + :ets.new(@child_spans_table, [:bag, :public, :named_table]) - def update_span(span_data) do - GenServer.call(__MODULE__, {:update_span, span_data}) - end + _ -> + :ok + end - def remove_span(span_id) do - GenServer.call(__MODULE__, {:remove_span, span_id}) + :ok end - def remove_child_spans(parent_span_id) do - GenServer.call(__MODULE__, {:remove_child_spans, parent_span_id}) - end - - def handle_call({:store_span, span_data}, _from, state) do + def store_span(span_data) do if span_data.parent_span_id == nil do - new_state = put_in(state, [:root_spans, span_data.span_id], span_data) - {:reply, :ok, new_state} + :ets.insert(@root_spans_table, {span_data.span_id, span_data}) else - new_state = - update_in(state, [:child_spans, span_data.parent_span_id], fn spans -> - (spans || []) ++ [span_data] - end) - - {:reply, :ok, new_state} + :ets.insert(@child_spans_table, {span_data.parent_span_id, span_data}) end + + :ok end - def handle_call({:get_root_span, span_id}, _from, state) do - {:reply, state.root_spans[span_id], state} + def get_root_span(span_id) do + case :ets.lookup(@root_spans_table, span_id) do + [{^span_id, span}] -> span + [] -> nil + end end - def handle_call({:get_child_spans, parent_span_id}, _from, state) do - {:reply, state.child_spans[parent_span_id] || [], state} + def get_child_spans(parent_span_id) do + :ets.lookup(@child_spans_table, parent_span_id) + |> Enum.map(fn {_parent_id, span} -> span end) end - def handle_call({:update_span, span_data}, _from, state) do + def update_span(span_data) do if span_data.parent_span_id == nil do - new_state = put_in(state, [:root_spans, span_data.span_id], span_data) - {:reply, :ok, new_state} + :ets.insert(@root_spans_table, {span_data.span_id, span_data}) else - new_state = - update_in(state, [:child_spans, span_data.parent_span_id], fn spans -> - Enum.map(spans || [], fn span -> - if span.span_id == span_data.span_id, do: span_data, else: span - end) - end) - - {:reply, :ok, new_state} + existing_spans = :ets.lookup(@child_spans_table, span_data.parent_span_id) + + :ets.delete(@child_spans_table, span_data.parent_span_id) + + Enum.each(existing_spans, fn {parent_id, span} -> + if span.span_id != span_data.span_id do + :ets.insert(@child_spans_table, {parent_id, span}) + end + end) + + :ets.insert(@child_spans_table, {span_data.parent_span_id, span_data}) end - end - def handle_call({:remove_span, span_id}, _from, state) do - new_state = %{ - state - | root_spans: Map.delete(state.root_spans, span_id), - child_spans: Map.delete(state.child_spans, span_id) - } + :ok + end - {:reply, :ok, new_state} + def remove_span(span_id) do + :ets.delete(@root_spans_table, span_id) + :ok end - def handle_call({:remove_child_spans, parent_span_id}, _from, state) do - new_state = %{state | child_spans: Map.delete(state.child_spans, parent_span_id)} - {:reply, :ok, new_state} + def remove_child_spans(parent_span_id) do + :ets.delete(@child_spans_table, parent_span_id) + :ok end end diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index a0c642f1..3eba907a 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -3,6 +3,26 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do import Sentry.TestHelpers + alias Sentry.Opentelemetry.SpanStorage + + setup do + # Create tables + SpanStorage.setup() + + on_exit(fn -> + # Only try to clean up tables if they exist + if :ets.whereis(:sentry_root_spans) != :undefined do + :ets.delete_all_objects(:sentry_root_spans) + end + + if :ets.whereis(:sentry_child_spans) != :undefined do + :ets.delete_all_objects(:sentry_child_spans) + end + end) + + :ok + end + defmodule TestEndpoint do require OpenTelemetry.Tracer, as: Tracer From 32ea20bcddc3eea1e0ae2ccc8a6d65c79050bca6 Mon Sep 17 00:00:00 2001 From: Savannah Manning Date: Fri, 6 Dec 2024 03:19:24 -0700 Subject: [PATCH 28/39] Refactor span storage (#817) * initial refactor with ETS for span storage * add genserver back * Fix updating root spans in ets * remove comment --------- Co-authored-by: Peter Solnica --- lib/sentry/application.ex | 4 +- lib/sentry/opentelemetry/span_storage.ex | 73 ++++++++++--------- .../opentelemetry/span_processor_test.exs | 12 +-- 3 files changed, 40 insertions(+), 49 deletions(-) diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 49c51ef5..7eb56571 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -10,9 +10,6 @@ defmodule Sentry.Application do config = Config.validate!() :ok = Config.persist(config) - # Setup ETS tables for span storage - Sentry.Opentelemetry.SpanStorage.setup() - http_client = Keyword.fetch!(config, :client) maybe_http_client_spec = @@ -30,6 +27,7 @@ defmodule Sentry.Application do Sentry.Sources, Sentry.Dedupe, Sentry.ClientReport.Sender, + Sentry.Opentelemetry.SpanStorage, {Sentry.Integrations.CheckInIDMappings, [ max_expected_check_in_time: diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index fd49229a..19b7061b 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -1,78 +1,79 @@ defmodule Sentry.Opentelemetry.SpanStorage do @moduledoc false + use GenServer - @root_spans_table :sentry_root_spans - @child_spans_table :sentry_child_spans + @table :span_storage - def setup do - case :ets.whereis(@root_spans_table) do - :undefined -> - :ets.new(@root_spans_table, [:set, :public, :named_table]) + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, nil, name: name) + end - _ -> - :ok - end + @impl true + def init(nil) do + _table = + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:named_table, :public, :bag]) + end - case :ets.whereis(@child_spans_table) do - :undefined -> - :ets.new(@child_spans_table, [:bag, :public, :named_table]) + {:ok, :no_state} + end - _ -> - :ok + def store_span(span_data) when span_data.parent_span_id == nil do + case :ets.lookup(@table, {:root_span, span_data.span_id}) do + [] -> :ets.insert(@table, {{:root_span, span_data.span_id}, span_data}) + _ -> :ok end - - :ok end def store_span(span_data) do - if span_data.parent_span_id == nil do - :ets.insert(@root_spans_table, {span_data.span_id, span_data}) - else - :ets.insert(@child_spans_table, {span_data.parent_span_id, span_data}) - end - - :ok + _ = :ets.insert(@table, {span_data.parent_span_id, span_data}) end def get_root_span(span_id) do - case :ets.lookup(@root_spans_table, span_id) do - [{^span_id, span}] -> span + case :ets.lookup(@table, {:root_span, span_id}) do + [{{:root_span, ^span_id}, span}] -> span [] -> nil end end def get_child_spans(parent_span_id) do - :ets.lookup(@child_spans_table, parent_span_id) + :ets.lookup(@table, parent_span_id) |> Enum.map(fn {_parent_id, span} -> span end) end def update_span(span_data) do if span_data.parent_span_id == nil do - :ets.insert(@root_spans_table, {span_data.span_id, span_data}) + case :ets.lookup(@table, {:root_span, span_data.span_id}) do + [] -> + :ets.insert(@table, {{:root_span, span_data.span_id}, span_data}) + + _ -> + :ets.delete(@table, {:root_span, span_data.span_id}) + :ets.insert(@table, {{:root_span, span_data.span_id}, span_data}) + end else - existing_spans = :ets.lookup(@child_spans_table, span_data.parent_span_id) - - :ets.delete(@child_spans_table, span_data.parent_span_id) + existing_spans = :ets.lookup(@table, span_data.parent_span_id) Enum.each(existing_spans, fn {parent_id, span} -> - if span.span_id != span_data.span_id do - :ets.insert(@child_spans_table, {parent_id, span}) + if span.span_id == span_data.span_id do + :ets.delete_object(@table, {parent_id, span}) + :ets.insert(@table, {span_data.parent_span_id, span_data}) end end) - - :ets.insert(@child_spans_table, {span_data.parent_span_id, span_data}) end :ok end def remove_span(span_id) do - :ets.delete(@root_spans_table, span_id) + :ets.delete(@table, {:root_span, span_id}) :ok end def remove_child_spans(parent_span_id) do - :ets.delete(@child_spans_table, parent_span_id) + :ets.delete(@table, parent_span_id) :ok end end diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index 3eba907a..beadee3b 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -6,17 +6,10 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do alias Sentry.Opentelemetry.SpanStorage setup do - # Create tables - SpanStorage.setup() - on_exit(fn -> # Only try to clean up tables if they exist - if :ets.whereis(:sentry_root_spans) != :undefined do - :ets.delete_all_objects(:sentry_root_spans) - end - - if :ets.whereis(:sentry_child_spans) != :undefined do - :ets.delete_all_objects(:sentry_child_spans) + if :ets.whereis(:span_storage) != :undefined do + :ets.delete_all_objects(:span_storage) end end) @@ -71,7 +64,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do 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 From 4265136a38da5c7660f45fb4ffd3faf3c96c0088 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 9 Dec 2024 11:12:01 +0000 Subject: [PATCH 29/39] Add tests for Sentry.send_transaction --- lib/sentry.ex | 12 +++++++++--- test/sentry_test.exs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/lib/sentry.ex b/lib/sentry.ex index 929a80c5..2dc9d563 100644 --- a/lib/sentry.ex +++ b/lib/sentry.ex @@ -362,16 +362,22 @@ defmodule Sentry do end end - def send_transaction(transaction, opts \\ []) do + def send_transaction(transaction, options \\ []) do # TODO: remove on v11.0.0, :included_environments was deprecated in 10.0.0. included_envs = Config.included_environments() cond do Config.test_mode?() -> - Client.send_transaction(transaction, opts) + Client.send_transaction(transaction, options) + + !Config.dsn() -> + # We still validate options even if we're not sending the event. This aims at catching + # configuration issues during development instead of only when deploying to production. + _options = NimbleOptions.validate!(options, Options.send_event_schema()) + :ignored included_envs == :all or to_string(Config.environment_name()) in included_envs -> - Client.send_transaction(transaction, opts) + Client.send_transaction(transaction, options) true -> :ignored diff --git a/test/sentry_test.exs b/test/sentry_test.exs index 80b452b8..93bbaa0b 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -235,4 +235,41 @@ defmodule SentryTest do assert Sentry.get_dsn() == random_dsn end end + + describe "send_transaction/2" do + setup do + transaction = + Sentry.Transaction.new(%{ + transaction: "test-transaction", + start_timestamp: System.system_time(:second), + timestamp: System.system_time(:second) + }) + + {:ok, transaction: transaction} + end + + test "sends transaction to Sentry when configured properly", %{ + bypass: bypass, + transaction: transaction + } do + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert [{headers, transaction_body}] = decode_envelope!(body) + + assert headers["type"] == "transaction" + assert Map.has_key?(headers, "length") + assert transaction_body["transaction"] == "test-transaction" + + Plug.Conn.send_resp(conn, 200, ~s<{"id": "340"}>) + end) + + assert {:ok, "340"} = Sentry.send_transaction(transaction) + end + + test "ignores transaction when dsn is not configured", %{transaction: transaction} do + put_test_config(dsn: nil, test_mode: false) + + assert :ignored = Sentry.send_transaction(transaction) + end + end end From 09456d8a3c5631eba5d69a4d73c393daa133f841 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 9 Dec 2024 11:13:42 +0000 Subject: [PATCH 30/39] Opentelemetry => OpenTelemetry --- config/config.exs | 2 +- lib/sentry/application.ex | 2 +- lib/sentry/opentelemetry/span_processor.ex | 5 +++-- lib/sentry/opentelemetry/span_record.ex | 2 +- lib/sentry/opentelemetry/span_storage.ex | 2 +- test/sentry/opentelemetry/span_processor_test.exs | 8 ++++---- test_integrations/phoenix_app/config/config.exs | 2 +- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/config/config.exs b/config/config.exs index 70bcc7be..f4575527 100644 --- a/config/config.exs +++ b/config/config.exs @@ -15,6 +15,6 @@ if config_env() == :test do config :logger, backends: [] end -config :opentelemetry, span_processor: {Sentry.Opentelemetry.SpanProcessor, []} +config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} config :phoenix, :json_library, Jason diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 7eb56571..86e0a306 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -27,7 +27,7 @@ defmodule Sentry.Application do Sentry.Sources, Sentry.Dedupe, Sentry.ClientReport.Sender, - Sentry.Opentelemetry.SpanStorage, + Sentry.OpenTelemetry.SpanStorage, {Sentry.Integrations.CheckInIDMappings, [ max_expected_check_in_time: diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 11caa0d0..1487329f 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -1,10 +1,11 @@ -defmodule Sentry.Opentelemetry.SpanProcessor do +defmodule Sentry.OpenTelemetry.SpanProcessor do @moduledoc false + @behaviour :otel_span_processor require Logger - alias Sentry.{Span, Transaction, Opentelemetry.SpanStorage, Opentelemetry.SpanRecord} + alias Sentry.{Span, Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord} @impl true def on_start(_ctx, otel_span, _config) do diff --git a/lib/sentry/opentelemetry/span_record.ex b/lib/sentry/opentelemetry/span_record.ex index d314e3b4..63988c4f 100644 --- a/lib/sentry/opentelemetry/span_record.ex +++ b/lib/sentry/opentelemetry/span_record.ex @@ -1,4 +1,4 @@ -defmodule Sentry.Opentelemetry.SpanRecord do +defmodule Sentry.OpenTelemetry.SpanRecord do require Record require OpenTelemetry diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index 19b7061b..741d65c1 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -1,4 +1,4 @@ -defmodule Sentry.Opentelemetry.SpanStorage do +defmodule Sentry.OpenTelemetry.SpanStorage do @moduledoc false use GenServer diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index beadee3b..63cf9b9d 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -1,9 +1,9 @@ -defmodule Sentry.Opentelemetry.SpanProcessorTest do +defmodule Sentry.OpenTelemetry.SpanProcessorTest do use Sentry.Case, async: false import Sentry.TestHelpers - alias Sentry.Opentelemetry.SpanStorage + alias Sentry.OpenTelemetry.SpanStorage setup do on_exit(fn -> @@ -102,10 +102,10 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() assert nil == - Sentry.Opentelemetry.SpanStorage.get_root_span(transaction.contexts.trace.span_id) + Sentry.OpenTelemetry.SpanStorage.get_root_span(transaction.contexts.trace.span_id) assert [] == - Sentry.Opentelemetry.SpanStorage.get_child_spans(transaction.contexts.trace.span_id) + Sentry.OpenTelemetry.SpanStorage.get_child_spans(transaction.contexts.trace.span_id) end defp assert_valid_iso8601(timestamp) do diff --git a/test_integrations/phoenix_app/config/config.exs b/test_integrations/phoenix_app/config/config.exs index fea6538d..5768bf77 100644 --- a/test_integrations/phoenix_app/config/config.exs +++ b/test_integrations/phoenix_app/config/config.exs @@ -61,7 +61,7 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason -config :opentelemetry, span_processor: {Sentry.Opentelemetry.SpanProcessor, []} +config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. From 5dc155caf3bb1244ccc7a111e75166f5a93186b9 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 9 Dec 2024 11:14:33 +0000 Subject: [PATCH 31/39] Use SpanStorage alias in the test --- test/sentry/opentelemetry/span_processor_test.exs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index 63cf9b9d..42283706 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -1,4 +1,4 @@ -defmodule Sentry.OpenTelemetry.SpanProcessorTest do +defmodule Sentry.Opentelemetry.SpanProcessorTest do use Sentry.Case, async: false import Sentry.TestHelpers @@ -101,11 +101,8 @@ defmodule Sentry.OpenTelemetry.SpanProcessorTest do assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() - assert nil == - Sentry.OpenTelemetry.SpanStorage.get_root_span(transaction.contexts.trace.span_id) - - assert [] == - Sentry.OpenTelemetry.SpanStorage.get_child_spans(transaction.contexts.trace.span_id) + assert nil == SpanStorage.get_root_span(transaction.contexts.trace.span_id) + assert [] == SpanStorage.get_child_spans(transaction.contexts.trace.span_id) end defp assert_valid_iso8601(timestamp) do From 75f781d60ef971bbad06a24e0b035c853d991a28 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 9 Dec 2024 12:06:27 +0000 Subject: [PATCH 32/39] Tests for SpanStorage --- .../opentelemetry/span_storage_test.exs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 test/sentry/opentelemetry/span_storage_test.exs diff --git a/test/sentry/opentelemetry/span_storage_test.exs b/test/sentry/opentelemetry/span_storage_test.exs new file mode 100644 index 00000000..3f994b50 --- /dev/null +++ b/test/sentry/opentelemetry/span_storage_test.exs @@ -0,0 +1,183 @@ +defmodule Sentry.OpenTelemetry.SpanStorageTest do + use ExUnit.Case, async: false + + alias Sentry.OpenTelemetry.{SpanStorage, SpanRecord} + + setup do + if :ets.whereis(:span_storage) != :undefined do + :ets.delete_all_objects(:span_storage) + else + start_supervised!(SpanStorage) + end + + on_exit(fn -> + if :ets.whereis(:span_storage) != :undefined do + :ets.delete_all_objects(:span_storage) + end + end) + + :ok + end + + describe "root spans" do + test "stores and retrieves a root span" do + root_span = %SpanRecord{ + span_id: "abc123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + SpanStorage.store_span(root_span) + + assert ^root_span = SpanStorage.get_root_span("abc123") + end + + test "updates an existing root span" 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) + SpanStorage.update_span(updated_root_span) + + assert ^updated_root_span = SpanStorage.get_root_span("abc123") + end + + test "removes a root span" do + root_span = %SpanRecord{ + span_id: "abc123", + parent_span_id: nil, + trace_id: "trace123", + name: "root_span" + } + + SpanStorage.store_span(root_span) + assert root_span == SpanStorage.get_root_span("abc123") + + SpanStorage.remove_span("abc123") + assert nil == SpanStorage.get_root_span("abc123") + end + end + + describe "child spans" do + test "stores and retrieves child spans" 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) + SpanStorage.store_span(child_span2) + + children = SpanStorage.get_child_spans("parent123") + assert length(children) == 2 + assert child_span1 in children + assert child_span2 in children + end + + test "updates an existing child span" 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) + SpanStorage.update_span(updated_child_span) + + children = SpanStorage.get_child_spans("parent123") + assert [^updated_child_span] = children + end + + test "removes child spans" 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) + SpanStorage.store_span(child_span2) + assert length(SpanStorage.get_child_spans("parent123")) == 2 + + SpanStorage.remove_child_spans("parent123") + assert [] == SpanStorage.get_child_spans("parent123") + end + end + + test "handles complete span hierarchy" 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) + SpanStorage.store_span(child_span1) + SpanStorage.store_span(child_span2) + + assert ^root_span = SpanStorage.get_root_span("root123") + + children = SpanStorage.get_child_spans("root123") + assert length(children) == 2 + assert child_span1 in children + assert child_span2 in children + + SpanStorage.remove_span("root123") + SpanStorage.remove_child_spans("root123") + + assert nil == SpanStorage.get_root_span("root123") + assert [] == SpanStorage.get_child_spans("root123") + end +end From e2ee522ce6d26ec995e3d6bc45cabebed6a78ca7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 9 Dec 2024 12:08:39 +0000 Subject: [PATCH 33/39] Remove child spans from SpanStorage automatically --- lib/sentry/opentelemetry/span_processor.ex | 1 - lib/sentry/opentelemetry/span_storage.ex | 8 +++- .../opentelemetry/span_storage_test.exs | 38 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index 1487329f..f7e40098 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -42,7 +42,6 @@ defmodule Sentry.OpenTelemetry.SpanProcessor do end SpanStorage.remove_span(span_record.span_id) - SpanStorage.remove_child_spans(span_record.span_id) result else diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index 741d65c1..387dfffc 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -68,8 +68,12 @@ defmodule Sentry.OpenTelemetry.SpanStorage do end def remove_span(span_id) do - :ets.delete(@table, {:root_span, span_id}) - :ok + case get_root_span(span_id) do + nil -> :ok + _root_span -> + :ets.delete(@table, {:root_span, span_id}) + remove_child_spans(span_id) + end end def remove_child_spans(parent_span_id) do diff --git a/test/sentry/opentelemetry/span_storage_test.exs b/test/sentry/opentelemetry/span_storage_test.exs index 3f994b50..d7d511a6 100644 --- a/test/sentry/opentelemetry/span_storage_test.exs +++ b/test/sentry/opentelemetry/span_storage_test.exs @@ -68,6 +68,44 @@ defmodule Sentry.OpenTelemetry.SpanStorageTest do SpanStorage.remove_span("abc123") assert nil == SpanStorage.get_root_span("abc123") end + + test "removes root span and all its children" 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) + SpanStorage.store_span(child_span1) + SpanStorage.store_span(child_span2) + + # Verify initial state + assert root_span == SpanStorage.get_root_span("root123") + assert length(SpanStorage.get_child_spans("root123")) == 2 + + # Remove root span should remove everything + SpanStorage.remove_span("root123") + + # Verify everything is removed + assert nil == SpanStorage.get_root_span("root123") + assert [] == SpanStorage.get_child_spans("root123") + end end describe "child spans" do From 4e8e7372dd8ebd900253270f96ca1e45d3d75b65 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 9 Dec 2024 12:36:56 +0000 Subject: [PATCH 34/39] Add sweeping of expired spans --- lib/sentry/opentelemetry/span_storage.ex | 80 ++++++++-- .../opentelemetry/span_storage_test.exs | 146 +++++++++++++++++- 2 files changed, 210 insertions(+), 16 deletions(-) diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index 387dfffc..9542d502 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -2,64 +2,76 @@ defmodule Sentry.OpenTelemetry.SpanStorage do @moduledoc false use GenServer + require Logger + @table :span_storage + @cleanup_interval :timer.minutes(5) + @span_ttl :timer.minutes(30) @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, nil, name: name) + GenServer.start_link(__MODULE__, opts, name: name) end @impl true - def init(nil) do + def init(opts) do _table = if :ets.whereis(@table) == :undefined do :ets.new(@table, [:named_table, :public, :bag]) end - {:ok, :no_state} + cleanup_interval = Keyword.get(opts, :cleanup_interval, @cleanup_interval) + schedule_cleanup(cleanup_interval) + + {:ok, %{cleanup_interval: cleanup_interval}} end def store_span(span_data) when span_data.parent_span_id == nil do + stored_at = System.system_time(:second) + case :ets.lookup(@table, {:root_span, span_data.span_id}) do - [] -> :ets.insert(@table, {{:root_span, span_data.span_id}, span_data}) + [] -> :ets.insert(@table, {{:root_span, span_data.span_id}, {span_data, stored_at}}) _ -> :ok end end def store_span(span_data) do - _ = :ets.insert(@table, {span_data.parent_span_id, span_data}) + stored_at = System.system_time(:second) + _ = :ets.insert(@table, {span_data.parent_span_id, {span_data, stored_at}}) end def get_root_span(span_id) do case :ets.lookup(@table, {:root_span, span_id}) do - [{{:root_span, ^span_id}, span}] -> span + [{{:root_span, ^span_id}, {span, _stored_at}}] -> span [] -> nil end end def get_child_spans(parent_span_id) do :ets.lookup(@table, parent_span_id) - |> Enum.map(fn {_parent_id, span} -> span end) + |> Enum.map(fn {_parent_id, {span, _stored_at}} -> span end) end def update_span(span_data) do + stored_at = System.system_time(:second) + if span_data.parent_span_id == nil do case :ets.lookup(@table, {:root_span, span_data.span_id}) do [] -> - :ets.insert(@table, {{:root_span, span_data.span_id}, span_data}) + :ets.insert(@table, {{:root_span, span_data.span_id}, {span_data, stored_at}}) _ -> :ets.delete(@table, {:root_span, span_data.span_id}) - :ets.insert(@table, {{:root_span, span_data.span_id}, span_data}) + :ets.insert(@table, {{:root_span, span_data.span_id}, {span_data, stored_at}}) end else existing_spans = :ets.lookup(@table, span_data.parent_span_id) - Enum.each(existing_spans, fn {parent_id, span} -> + Enum.each(existing_spans, fn {parent_id, {span, stored_at}} -> if span.span_id == span_data.span_id do - :ets.delete_object(@table, {parent_id, span}) - :ets.insert(@table, {span_data.parent_span_id, span_data}) + :ets.delete_object(@table, {parent_id, {span, stored_at}}) + :ets.insert(@table, {span_data.parent_span_id, {span_data, stored_at}}) end end) end @@ -69,7 +81,9 @@ defmodule Sentry.OpenTelemetry.SpanStorage do def remove_span(span_id) do case get_root_span(span_id) do - nil -> :ok + nil -> + :ok + _root_span -> :ets.delete(@table, {:root_span, span_id}) remove_child_spans(span_id) @@ -80,4 +94,44 @@ defmodule Sentry.OpenTelemetry.SpanStorage do :ets.delete(@table, parent_span_id) :ok end + + @impl true + def handle_info(:cleanup_stale_spans, state) do + cleanup_stale_spans() + schedule_cleanup(state.cleanup_interval) + {:noreply, state} + end + + defp schedule_cleanup(interval) do + Process.send_after(self(), :cleanup_stale_spans, interval) + end + + defp cleanup_stale_spans do + now = System.system_time(:second) + cutoff_time = now - @span_ttl + + :ets.match_object(@table, {{:root_span, :_}, {:_, :_}}) + |> Enum.each(fn {{:root_span, span_id}, {_span, stored_at}} -> + if stored_at < cutoff_time do + Logger.debug("Cleaning up stale root span: #{span_id}") + remove_span(span_id) + end + end) + + :ets.match_object(@table, {:_, {:_, :_}}) + |> Enum.each(fn {parent_id, {span, stored_at}} = object -> + cond do + get_root_span(parent_id) != nil and stored_at < cutoff_time -> + Logger.debug("Cleaning up stale child span: #{span.span_id}") + :ets.delete_object(@table, object) + + get_root_span(parent_id) == nil and stored_at < cutoff_time -> + Logger.debug("Cleaning up stale orphaned child span: #{span.span_id}") + :ets.delete_object(@table, object) + + true -> + :ok + end + end) + end end diff --git a/test/sentry/opentelemetry/span_storage_test.exs b/test/sentry/opentelemetry/span_storage_test.exs index d7d511a6..df9f3ec7 100644 --- a/test/sentry/opentelemetry/span_storage_test.exs +++ b/test/sentry/opentelemetry/span_storage_test.exs @@ -95,14 +95,11 @@ defmodule Sentry.OpenTelemetry.SpanStorageTest do SpanStorage.store_span(child_span1) SpanStorage.store_span(child_span2) - # Verify initial state assert root_span == SpanStorage.get_root_span("root123") assert length(SpanStorage.get_child_spans("root123")) == 2 - # Remove root span should remove everything SpanStorage.remove_span("root123") - # Verify everything is removed assert nil == SpanStorage.get_root_span("root123") assert [] == SpanStorage.get_child_spans("root123") end @@ -218,4 +215,147 @@ defmodule Sentry.OpenTelemetry.SpanStorageTest do assert nil == SpanStorage.get_root_span("root123") assert [] == SpanStorage.get_child_spans("root123") end + + describe "stale span cleanup" do + test "cleans up stale spans" do + start_supervised!({SpanStorage, cleanup_interval: 100, name: :cleanup_test}) + + 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 = System.system_time(:second) - :timer.minutes(31) + :ets.insert(:span_storage, {{:root_span, "stale_root"}, {root_span, old_time}}) + :ets.insert(:span_storage, {"stale_root", {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) + + Process.sleep(200) + + assert nil == SpanStorage.get_root_span("stale_root") + assert [] == SpanStorage.get_child_spans("stale_root") + + assert SpanStorage.get_root_span("fresh_root") + end + + test "cleans up orphaned child spans" do + start_supervised!({SpanStorage, cleanup_interval: 100, name: :cleanup_test}) + + child_span = %SpanRecord{ + span_id: "stale_child", + parent_span_id: "non_existent_parent", + trace_id: "trace123", + name: "stale_child_span" + } + + old_time = System.system_time(:second) - :timer.minutes(31) + :ets.insert(:span_storage, {"non_existent_parent", {child_span, old_time}}) + + Process.sleep(200) + + assert [] == SpanStorage.get_child_spans("non_existent_parent") + end + + test "cleans up expired root spans with all their children regardless of child timestamps" do + start_supervised!({SpanStorage, cleanup_interval: 100, name: :cleanup_test}) + + 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 = System.system_time(:second) - :timer.minutes(31) + :ets.insert(:span_storage, {{:root_span, "root123"}, {root_span, old_time}}) + + :ets.insert(:span_storage, {"root123", {old_child, old_time}}) + SpanStorage.store_span(fresh_child) + + Process.sleep(200) + + assert nil == SpanStorage.get_root_span("root123") + assert [] == SpanStorage.get_child_spans("root123") + end + + test "handles mixed expiration times in child spans" do + start_supervised!({SpanStorage, cleanup_interval: 100, name: :cleanup_test}) + + 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) + + old_time = System.system_time(:second) - :timer.minutes(31) + :ets.insert(:span_storage, {"root123", {old_child1, old_time}}) + :ets.insert(:span_storage, {"root123", {old_child2, old_time}}) + + SpanStorage.store_span(fresh_child) + + Process.sleep(200) + + assert root_span == SpanStorage.get_root_span("root123") + children = SpanStorage.get_child_spans("root123") + assert length(children) == 1 + assert fresh_child in children + refute old_child1 in children + refute old_child2 in children + end + end end From bf9e35f4f46b25e2dde10b023962a27cb8ac15d2 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 9 Dec 2024 12:49:14 +0000 Subject: [PATCH 35/39] Make opentelemetry libs optional deps --- mix.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index b107fda8..f1c27044 100644 --- a/mix.exs +++ b/mix.exs @@ -112,8 +112,8 @@ defmodule Sentry.Mixfile do {:floki, ">= 0.30.0", only: :test}, {:oban, "~> 2.17 and >= 2.17.6", only: [:test]}, {:quantum, "~> 3.0", only: [:test]}, - {:opentelemetry, "~> 1.4"}, - {:opentelemetry_api, "~> 1.3"} + {:opentelemetry, "~> 1.4", optional: true}, + {:opentelemetry_api, "~> 1.3", optional: true} ] end From 7490f9c04c997e95098b85b957b9dd1c31f4a63e Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 13 Dec 2024 16:19:28 +0000 Subject: [PATCH 36/39] Fix tests under 1.13 Turns out `optional` deps in mix.exs work differently in older Elixir, as it would not load opentelemetry deps which made our tests fail because opentelemetry setup was not loaded. --- mix.exs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index f1c27044..e59b1e84 100644 --- a/mix.exs +++ b/mix.exs @@ -112,8 +112,10 @@ defmodule Sentry.Mixfile do {:floki, ">= 0.30.0", only: :test}, {:oban, "~> 2.17 and >= 2.17.6", only: [:test]}, {:quantum, "~> 3.0", only: [:test]}, - {:opentelemetry, "~> 1.4", optional: true}, - {:opentelemetry_api, "~> 1.3", optional: true} + + # Required by Tracing + {:opentelemetry, "~> 1.4", only: [:test]}, + {:opentelemetry_api, "~> 1.3", only: [:test]} ] end From 83b016263e288e24db5175bc9d698299464f6563 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 13 Dec 2024 16:53:18 +0000 Subject: [PATCH 37/39] Support Transaction in client reports --- lib/sentry/envelope.ex | 10 +++++++++- test/sentry/client_report/sender_test.exs | 22 +++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/lib/sentry/envelope.ex b/lib/sentry/envelope.ex index 872fd177..9e3fdb3e 100644 --- a/lib/sentry/envelope.ex +++ b/lib/sentry/envelope.ex @@ -63,12 +63,20 @@ defmodule Sentry.Envelope do Returns the "data category" of the envelope's contents (to be used in client reports and more). """ @doc since: "10.8.0" - @spec get_data_category(Attachment.t() | CheckIn.t() | ClientReport.t() | Event.t()) :: + @spec get_data_category( + Attachment.t() + | CheckIn.t() + | ClientReport.t() + | Event.t() + | Transaction.t() + ) :: String.t() def get_data_category(%Attachment{}), do: "attachment" def get_data_category(%CheckIn{}), do: "monitor" def get_data_category(%ClientReport{}), do: "internal" def get_data_category(%Event{}), do: "error" + # TODO: is this correct? + def get_data_category(%Transaction{}), do: "error" @doc """ Encodes the envelope into its binary representation. diff --git a/test/sentry/client_report/sender_test.exs b/test/sentry/client_report/sender_test.exs index 8d502262..a349018c 100644 --- a/test/sentry/client_report/sender_test.exs +++ b/test/sentry/client_report/sender_test.exs @@ -4,7 +4,7 @@ defmodule Sentry.ClientReportTest do import Sentry.TestHelpers alias Sentry.ClientReport.Sender - alias Sentry.Event + alias Sentry.{Event, Transaction} setup do original_retries = @@ -27,24 +27,28 @@ defmodule Sentry.ClientReportTest do %Event{ event_id: Sentry.UUID.uuid4_hex(), timestamp: "2024-10-12T13:21:13" + }, + %Transaction{ + event_id: Sentry.UUID.uuid4_hex(), + timestamp: "2024-10-12T13:21:13" } ] assert :ok = Sender.record_discarded_events(:before_send, events, :test_client_report) - assert :sys.get_state(:test_client_report) == %{{:before_send, "error"} => 1} + assert :sys.get_state(:test_client_report) == %{{:before_send, "error"} => 2} assert :ok = Sender.record_discarded_events(:before_send, events, :test_client_report) - assert :sys.get_state(:test_client_report) == %{{:before_send, "error"} => 2} + assert :sys.get_state(:test_client_report) == %{{:before_send, "error"} => 4} assert :ok = Sender.record_discarded_events(:event_processor, events, :test_client_report) assert :ok = Sender.record_discarded_events(:network_error, events, :test_client_report) assert :sys.get_state(:test_client_report) == %{ - {:before_send, "error"} => 2, - {:event_processor, "error"} => 1, - {:network_error, "error"} => 1 + {:before_send, "error"} => 4, + {:event_processor, "error"} => 2, + {:network_error, "error"} => 2 } send(Process.whereis(:test_client_report), :send_report) @@ -56,9 +60,9 @@ defmodule Sentry.ClientReportTest do decode_envelope!(body) assert client_report["discarded_events"] == [ - %{"reason" => "before_send", "category" => "error", "quantity" => 2}, - %{"reason" => "event_processor", "category" => "error", "quantity" => 1}, - %{"reason" => "network_error", "category" => "error", "quantity" => 1} + %{"reason" => "before_send", "category" => "error", "quantity" => 4}, + %{"reason" => "event_processor", "category" => "error", "quantity" => 2}, + %{"reason" => "network_error", "category" => "error", "quantity" => 2} ] assert client_report["timestamp"] =~ ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ From ef1b77caebd41f20b24c4f684163d5dce2335654 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 13 Dec 2024 23:16:46 +0000 Subject: [PATCH 38/39] wip - initial work on oban support --- lib/sentry/opentelemetry/span_processor.ex | 57 +++++++++++++++++++ mix.exs | 4 +- mix.lock | 4 +- test_integrations/phoenix_app/config/dev.exs | 5 ++ test_integrations/phoenix_app/config/test.exs | 5 ++ .../lib/phoenix_app/application.ex | 5 +- test_integrations/phoenix_app/mix.exs | 6 +- test_integrations/phoenix_app/mix.lock | 2 + .../test/phoenix_app/oban_test.exs | 44 ++++++++++++++ 9 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 test_integrations/phoenix_app/test/phoenix_app/oban_test.exs diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index f7e40098..ae0ac114 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -172,6 +172,22 @@ defmodule Sentry.OpenTelemetry.SpanProcessor do }) end + defp build_transaction(%SpanRecord{origin: "opentelemetry_oban"} = root_span, child_spans) do + Transaction.new(%{ + transaction: root_span.name |> String.split(" ") |> List.first(), + start_timestamp: root_span.start_time, + timestamp: root_span.end_time, + transaction_info: %{ + source: "task" + }, + contexts: %{ + trace: build_trace_context(root_span) + }, + measurements: %{}, + spans: Enum.map(child_spans, &build_span(&1)) + }) + end + defp build_trace_context( %SpanRecord{origin: "opentelemetry_phoenix", attributes: attributes} = root_span ) @@ -217,6 +233,47 @@ defmodule Sentry.OpenTelemetry.SpanProcessor do } end + defp build_trace_context( + %SpanRecord{ + origin: "opentelemetry_oban", + attributes: %{"oban.plugin" => Oban.Stager} = _attributes + } = root_span + ) do + %{ + trace_id: root_span.trace_id, + span_id: root_span.span_id, + parent_span_id: root_span.parent_span_id, + op: "queue.process", + origin: root_span.origin, + data: %{ + "oban.plugin" => "stager" + } + } + end + + defp build_trace_context( + %SpanRecord{origin: "opentelemetry_oban", attributes: attributes} = root_span + ) do + now = DateTime.utc_now() + {:ok, scheduled_at, _} = DateTime.from_iso8601(attributes["oban.job.scheduled_at"]) + + latency = DateTime.diff(now, scheduled_at, :millisecond) + + %{ + trace_id: root_span.trace_id, + span_id: root_span.span_id, + parent_span_id: root_span.parent_span_id, + op: "queue.process", + origin: root_span.origin, + data: %{ + id: attributes["oban.job.job_id"], + queue: attributes["messaging.destination"], + retry_count: attributes["oban.job.attempt"], + latency: latency + } + } + end + defp build_trace_context(%SpanRecord{attributes: attributes} = root_span) do %{ trace_id: root_span.trace_id, diff --git a/mix.exs b/mix.exs index e59b1e84..fa54051c 100644 --- a/mix.exs +++ b/mix.exs @@ -114,8 +114,8 @@ defmodule Sentry.Mixfile do {:quantum, "~> 3.0", only: [:test]}, # Required by Tracing - {:opentelemetry, "~> 1.4", only: [:test]}, - {:opentelemetry_api, "~> 1.3", only: [:test]} + {:opentelemetry, "~> 1.5"}, + {:opentelemetry_api, "~> 1.3"} ] end diff --git a/mix.lock b/mix.lock index eb742f2e..634fc095 100644 --- a/mix.lock +++ b/mix.lock @@ -32,8 +32,8 @@ "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "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.4.0", "f928923ed80adb5eb7894bac22e9a198478e6a8f04020ae1d6f289fdcad0b498", [:rebar3], [{:opentelemetry_api, "~> 1.3.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "50b32ce127413e5d87b092b4d210a3449ea80cd8224090fe68d73d576a3faa15"}, - "opentelemetry_api": {:hex, :opentelemetry_api, "1.3.1", "83b4713593f80562d9643c4ab0b6f80f3c5fa4c6d0632c43e11b2ccb6b04dfa7", [:mix, :rebar3], [{:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "9e8a5cc38671e3ac61be48abe5f6b3afdbbb50a1dc08b7950c56f169611505c1"}, + "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_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "0.2.0", "b67fe459c2938fcab341cb0951c44860c62347c005ace1b50f8402576f241435", [:mix, :rebar3], [], "hexpm", "d61fa1f5639ee8668d74b527e6806e0503efc55a42db7b5f39939d84c07d6895"}, "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"}, diff --git a/test_integrations/phoenix_app/config/dev.exs b/test_integrations/phoenix_app/config/dev.exs index 5fb8095a..1897f688 100644 --- a/test_integrations/phoenix_app/config/dev.exs +++ b/test_integrations/phoenix_app/config/dev.exs @@ -89,3 +89,8 @@ config :sentry, environment_name: :dev, enable_source_code_context: true, send_result: :sync + +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 c7c89488..e19bd5ac 100644 --- a/test_integrations/phoenix_app/config/test.exs +++ b/test_integrations/phoenix_app/config/test.exs @@ -35,3 +35,8 @@ config :sentry, enable_source_code_context: true, send_result: :sync, test_mode: true + +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/application.ex b/test_integrations/phoenix_app/lib/phoenix_app/application.ex index ee64ab13..b831628b 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app/application.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app/application.ex @@ -15,6 +15,7 @@ defmodule PhoenixApp.Application do # OpentelemetryBandit.setup() OpentelemetryPhoenix.setup() + OpentelemetryOban.setup() OpentelemetryEcto.setup([:phoenix_app, :repo], db_statement: :enabled) children = [ @@ -26,8 +27,8 @@ defmodule PhoenixApp.Application do {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 ] diff --git a/test_integrations/phoenix_app/mix.exs b/test_integrations/phoenix_app/mix.exs index c04f3959..6244a049 100644 --- a/test_integrations/phoenix_app/mix.exs +++ b/test_integrations/phoenix_app/mix.exs @@ -64,13 +64,15 @@ defmodule PhoenixApp.MixProject do {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, {:bypass, "~> 2.1", only: :test}, - {:opentelemetry, "~> 1.4"}, + {:opentelemetry, "~> 1.5"}, {:opentelemetry_api, "~> 1.3"}, {:opentelemetry_phoenix, "~> 1.2"}, + {:opentelemetry_oban, "~> 1.1"}, # {:opentelemetry_bandit, "~> 0.1.4", github: "solnic/opentelemetry-bandit", depth: 1}, {:opentelemetry_ecto, "~> 1.2"}, {:sentry, path: "../.."}, - {:hackney, "~> 1.18"} + {:hackney, "~> 1.18"}, + {:oban, "~> 2.10"} ] end diff --git a/test_integrations/phoenix_app/mix.lock b/test_integrations/phoenix_app/mix.lock index beb072c7..3160caed 100644 --- a/test_integrations/phoenix_app/mix.lock +++ b/test_integrations/phoenix_app/mix.lock @@ -33,10 +33,12 @@ "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.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_bandit": {:git, "https://github.com/solnic/opentelemetry-bandit.git", "1e00505fb3bb02001a3400f8a807cd1c7f7f957d", []}, "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_oban": {:hex, :opentelemetry_oban, "1.1.1", "519e9ba60d3dc3483ad2df3fade131d47056e0dae74f0724c8a40b9718f089d1", [:mix], [{:oban, "~> 2.0", [hex: :oban, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ae6aed431626a94a4bb6bf5b268247ced687ec8f99eced6887e3754f9d3a2089"}, "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "1.2.0", "b8a53ee595b24970571a7d2fcaef3e4e1a021c68e97cac163ca5d9875fad5e9f", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.0", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "acab991d14ed3efc3f780c5a20cabba27149cf731005b1cc6454c160859debe5"}, "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, "0.2.0", "b67fe459c2938fcab341cb0951c44860c62347c005ace1b50f8402576f241435", [:mix, :rebar3], [], "hexpm", "d61fa1f5639ee8668d74b527e6806e0503efc55a42db7b5f39939d84c07d6895"}, 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..ec4c98f0 --- /dev/null +++ b/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs @@ -0,0 +1,44 @@ +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") + 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: "task"} + + trace = transaction.contexts.trace + assert trace.origin == "opentelemetry_oban" + assert trace.op == "queue.process" + assert trace.data.id + assert trace.data.queue == "default" + assert trace.data.retry_count == 1 + assert trace.data.latency > 0 + + assert [] = transaction.spans + end +end From 96d50e68229963206c3729a0d924ce0eb2c9aa1e Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 13 Dec 2024 23:18:05 +0000 Subject: [PATCH 39/39] wip - add a UI for testing Oban workers --- .../lib/phoenix_app/workers/test_worker.ex | 21 ++++++ .../phoenix_app_web/live/test_worker_live.ex | 60 ++++++++++++++++ .../live/test_worker_live.html.heex | 72 +++++++++++++++++++ .../phoenix_app/lib/phoenix_app_web/router.ex | 2 + .../migrations/20241213222834_add_oban.exs | 11 +++ 5 files changed, 166 insertions(+) create mode 100644 test_integrations/phoenix_app/lib/phoenix_app/workers/test_worker.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex create mode 100644 test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex create mode 100644 test_integrations/phoenix_app/priv/repo/migrations/20241213222834_add_oban.exs 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/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..4aa41bb0 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex @@ -0,0 +1,60 @@ +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"}), + 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 TestWorker.new( + %{"sleep_time" => sleep_time, "should_fail" => should_fail}, + queue: queue + ) + |> Oban.insert() 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_info(:update_jobs, socket) do + {:noreply, assign(socket, jobs: list_jobs())} + 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..b64247dd --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex @@ -0,0 +1,72 @@ +
+
+
+

Schedule Test Worker

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

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/router.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex index c1bac216..ddf33edf 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex @@ -21,6 +21,8 @@ defmodule PhoenixAppWeb.Router do 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 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