Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Traces/Transactions via Opentelemetry #784

Closed
wants to merge 53 commits into from
Closed
Changes from 1 commit
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
ec16ca8
Support configurable DSN for phoenix test app
solnic Sep 24, 2024
f214b82
Initial work on OTel-based Transactions
solnic Sep 4, 2024
59f0c2b
Move SpanStorage to its own file
solnic Sep 25, 2024
025ec6e
Rename Sentry.Telemetry => Sentry.Opentelemetry
solnic Sep 25, 2024
f7fb086
Introduce SpanRecord to simplify processing
solnic Sep 25, 2024
ab82965
Move casting trace_id to the SpanRecord struct
solnic Sep 25, 2024
e953b26
Move casting of span ids to the SpanRecord struct
solnic Sep 25, 2024
2d97073
No need to pass trace_id around anymore
solnic Sep 25, 2024
91ef16f
Handle casting timestamps in the SpanRecord struct
solnic Sep 25, 2024
4f2e63b
Extract SpanRecord into its own file
solnic Sep 25, 2024
6e19f48
Remove redundant function
solnic Sep 25, 2024
fca71df
Move setting platform info to the Client
solnic Sep 25, 2024
2f62ce7
Extract setting root span context
solnic Sep 25, 2024
fc91ca7
Improve ecto span/transaction
solnic Sep 25, 2024
a79dbc3
Unify top-level ecto transactions and ecto spans
solnic Sep 25, 2024
e9b6ef5
Return full URL and status info in Phoenix transactions
solnic Sep 25, 2024
b8c0c86
Fix extracting span record when sentry is an external dep
solnic Sep 25, 2024
5883053
Support for liveview traces/spans
solnic Sep 26, 2024
3155116
Remove span records after sending a transaction
solnic Sep 26, 2024
d2e3669
Fix formatting
solnic Sep 26, 2024
23f7122
Fix formatting
solnic Sep 26, 2024
83180e6
Fix dialyzer warnings
solnic Sep 27, 2024
770c67e
More dialyzer fixes
solnic Sep 27, 2024
8d9e81d
Fix build for 1.16
solnic Oct 23, 2024
8bab636
Remove debugging statement
solnic Oct 23, 2024
72c7d98
Update lib/sentry/opentelemetry/span_processor.ex
solnic Oct 25, 2024
7061f3c
WIP - rework SpanStorage to use ETS
solnic Oct 28, 2024
631b824
Refactor span storage (#817)
savhappy Dec 6, 2024
3dd1a94
Add tests for Sentry.send_transaction
solnic Dec 9, 2024
aa9f5f5
Opentelemetry => OpenTelemetry
solnic Dec 9, 2024
6301fe5
Use SpanStorage alias in the test
solnic Dec 9, 2024
3e93cfa
Tests for SpanStorage
solnic Dec 9, 2024
b7eb637
Remove child spans from SpanStorage automatically
solnic Dec 9, 2024
70f8e61
Add sweeping of expired spans
solnic Dec 9, 2024
54d1f20
Make opentelemetry libs optional deps
solnic Dec 9, 2024
6788ad5
Fix tests under 1.13
solnic Dec 13, 2024
7b2fc02
Support Transaction in client reports
solnic Dec 13, 2024
4f3ada1
wip - initial work on oban support
solnic Dec 13, 2024
1856faa
wip - add a UI for testing Oban workers
solnic Dec 13, 2024
6c2d80c
Refactor OTel (#835)
danschultzer Dec 18, 2024
b7b1dc6
Update dep specs for opentelemetry_*
solnic Dec 18, 2024
10224eb
[tmp] use opentelemetry_oban from git
solnic Dec 18, 2024
f67446e
Fix warning in get_op_description
solnic Dec 18, 2024
4a175a9
Fix formatting
solnic Dec 18, 2024
d2f88a2
Refine how we set values for Oban jobs
solnic Dec 18, 2024
469b5cd
Extend the test worker UI with auto-scheduling
solnic Dec 18, 2024
9c59d36
Filter out Oban internal traces
solnic Dec 18, 2024
0832054
Oops, fix filtering Oban Stager spans
solnic Dec 18, 2024
01fcd87
Address dialyzer warning (I think)
solnic Dec 18, 2024
4c32d87
Set sdk version dynamically
solnic Dec 19, 2024
9b4d700
Fix client report data category for transactions
solnic Dec 20, 2024
7ad38bb
Add Sentry's Sampler for OTel
solnic Dec 20, 2024
a808377
Remove conditional we longer need
solnic Dec 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Initial work on OTel-based Transactions
solnic committed Dec 27, 2024
commit f214b82dfcee615d3eaaaea4d9d5425dfb06ff93
2 changes: 2 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions lib/sentry.ex
Original file line number Diff line number Diff line change
@@ -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`.

1 change: 1 addition & 0 deletions lib/sentry/application.ex
Original file line number Diff line number Diff line change
@@ -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:
67 changes: 66 additions & 1 deletion lib/sentry/client.ex
Original file line number Diff line number Diff line change
@@ -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)

Comment on lines +112 to +113

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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()
24 changes: 23 additions & 1 deletion lib/sentry/envelope.ex
Original file line number Diff line number Diff line change
@@ -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
387 changes: 387 additions & 0 deletions lib/sentry/telemetry/span_processor.ex
Original file line number Diff line number Diff line change
@@ -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
96 changes: 93 additions & 3 deletions lib/sentry/test.ex
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions lib/sentry/transaction.ex
Original file line number Diff line number Diff line change
@@ -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
72 changes: 71 additions & 1 deletion lib/sentry/transport/sender.ex
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions lib/sentry/transport/sender_pool.ex
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -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

3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
57 changes: 57 additions & 0 deletions test/envelope_test.exs
Original file line number Diff line number Diff line change
@@ -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 <App>",
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
102 changes: 102 additions & 0 deletions test/sentry/telemetry/span_processor_test.exs
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions test_integrations/phoenix_app/config/config.exs
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions test_integrations/phoenix_app/config/dev.exs
Original file line number Diff line number Diff line change
@@ -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.
#
14 changes: 9 additions & 5 deletions test_integrations/phoenix_app/config/test.exs
Original file line number Diff line number Diff line change
@@ -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
Binary file added test_integrations/phoenix_app/db/dev.sqlite3
Binary file not shown.
Binary file not shown.
Empty file.
Binary file added test_integrations/phoenix_app/db/test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file added test_integrations/phoenix_app/db/test.sqlite3-wal
Binary file not shown.
28 changes: 22 additions & 6 deletions test_integrations/phoenix_app/lib/phoenix_app/application.ex
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions test_integrations/phoenix_app/lib/phoenix_app/repo.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule PhoenixApp.Repo do
use Ecto.Repo,
otp_app: :phoenix_app,
adapter: Ecto.Adapters.SQLite3
end
9 changes: 9 additions & 0 deletions test_integrations/phoenix_app/lib/phoenix_app/user.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule PhoenixApp.User do
use Ecto.Schema

schema "users" do
field :name, :string

timestamps()
end
end
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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,
2 changes: 2 additions & 0 deletions test_integrations/phoenix_app/lib/phoenix_app_web/router.ex
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 19 additions & 2 deletions test_integrations/phoenix_app/mix.exs
Original file line number Diff line number Diff line change
@@ -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

15 changes: 15 additions & 0 deletions test_integrations/phoenix_app/mix.lock
Original file line number Diff line number Diff line change
@@ -2,20 +2,27 @@
"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"},
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
"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"},
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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