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

OpenTelemetry integration #843

Closed
wants to merge 8 commits into from
Closed
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -15,4 +15,9 @@ if config_env() == :test do
config :logger, backends: []
end

config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []}

config :opentelemetry,
sampler: {Sentry.OpenTelemetry.Sampler, [drop: ["Elixir.Oban.Stager process"]]}

config :phoenix, :json_library, Jason
22 changes: 22 additions & 0 deletions lib/sentry.ex
Original file line number Diff line number Diff line change
@@ -362,6 +362,28 @@ defmodule Sentry do
end
end

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, 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, options)

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.OpenTelemetry.SpanStorage,
{Sentry.Integrations.CheckInIDMappings,
[
max_expected_check_in_time:
74 changes: 72 additions & 2 deletions lib/sentry/client.ex
Original file line number Diff line number Diff line change
@@ -15,8 +15,9 @@ defmodule Sentry.Client do
Event,
Interfaces,
LoggerUtils,
Transport,
Options
Options,
Transaction,
Transport
}

require Logger
@@ -107,6 +108,26 @@ defmodule Sentry.Client do
|> Transport.encode_and_post_envelope(client, request_retries)
end

def send_transaction(%Transaction{} = transaction, opts \\ []) do
opts = NimbleOptions.validate!(opts, Options.send_transaction_schema())

result_type = Keyword.get_lazy(opts, :result, &Config.send_result/0)
client = Keyword.get_lazy(opts, :client, &Config.client/0)

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, %ClientError{} = error} ->
{:error, error}
end
end

defp sample_event(sample_rate) do
cond do
sample_rate == 1 -> :ok
@@ -205,6 +226,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 +282,19 @@ 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
|> Transaction.to_map()
|> Map.merge(%{
platform: "elixir",
sdk: %{
name: "sentry.elixir",
version: Application.spec(:sentry, :vsn)
}
})
end

defp render_exception(%Interfaces.Exception{} = exception) do
exception
|> Map.from_struct()
45 changes: 42 additions & 3 deletions lib/sentry/envelope.ex
Original file line number Diff line number Diff line change
@@ -2,11 +2,21 @@ 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,
Transaction,
UUID
}

@type t() :: %__MODULE__{
event_id: UUID.t(),
items: [Event.t() | Attachment.t() | CheckIn.t() | ClientReport.t(), ...]
items: [
Attachment.t() | CheckIn.t() | ClientReport.t() | Event.t() | Transaction.t()
]
}

@enforce_keys [:event_id, :items]
@@ -46,13 +56,31 @@ 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).
"""
@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(%Transaction{}), do: "transaction"
def get_data_category(%CheckIn{}), do: "monitor"
def get_data_category(%ClientReport{}), do: "internal"
def get_data_category(%Event{}), do: "error"
@@ -126,4 +154,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
27 changes: 27 additions & 0 deletions lib/sentry/opentelemetry/sampler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
defmodule Sentry.OpenTelemetry.Sampler do
@moduledoc false

def setup(config) do
config
end

def description(_) do
"SentrySampler"
end

def should_sample(
_ctx,
_trace_id,
_links,
span_name,
_span_kind,
_attributes,
config
) do
if span_name in config[:drop] do
{:drop, [], []}
else
{:record_and_sample, [], []}
end
end
end
191 changes: 191 additions & 0 deletions lib/sentry/opentelemetry/span_processor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
defmodule Sentry.OpenTelemetry.SpanProcessor do
@moduledoc false

require OpenTelemetry.SemConv.ClientAttributes, as: ClientAttributes
require OpenTelemetry.SemConv.Incubating.DBAttributes, as: DBAttributes
require OpenTelemetry.SemConv.Incubating.HTTPAttributes, as: HTTPAttributes
require OpenTelemetry.SemConv.Incubating.URLAttributes, as: URLAttributes
require OpenTelemetry.SemConv.Incubating.MessagingAttributes, as: MessagingAttributes
@behaviour :otel_span_processor

require Logger

alias Sentry.{Span, Transaction, OpenTelemetry.SpanStorage, OpenTelemetry.SpanRecord}

@impl true
def on_start(_ctx, otel_span, _config) do
span_record = SpanRecord.new(otel_span)

SpanStorage.store_span(span_record)

otel_span
end

@impl true
def on_end(otel_span, _config) do
span_record = SpanRecord.new(otel_span)

SpanStorage.update_span(span_record)

if span_record.parent_span_id == nil do
root_span_record = SpanStorage.get_root_span(span_record.span_id)
child_span_records = SpanStorage.get_child_spans(span_record.span_id)
transaction = build_transaction(root_span_record, child_span_records)

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

:ok = SpanStorage.remove_span(span_record.span_id)

result
else
true
end
end

@impl true
def force_flush(_config) do
:ok
end

defp build_transaction(root_span_record, child_span_records) do
root_span = build_span(root_span_record)
child_spans = Enum.map(child_span_records, &build_span(&1))

Transaction.new(%{
span_id: root_span.span_id,
transaction: transaction_name(root_span_record),
transaction_info: %{source: :custom},
contexts: %{
trace: build_trace_context(root_span_record),
otel: build_otel_context(root_span_record)
},
spans: [root_span | child_spans]
})
end

defp transaction_name(
%{attributes: %{unquote(to_string(MessagingAttributes.messaging_system())) => :oban}} =
span_record
) do
span_record.attributes["oban.job.worker"]
end

defp transaction_name(span_record), do: span_record.name

defp build_trace_context(span_record) do
{op, description} = get_op_description(span_record)

%{
trace_id: span_record.trace_id,
span_id: span_record.span_id,
parent_span_id: span_record.parent_span_id,
op: op,
description: description,
origin: span_record.origin,
data: span_record.attributes
}
end

defp build_otel_context(span_record), do: span_record.attributes

defp get_op_description(
%{
attributes: %{
unquote(to_string(HTTPAttributes.http_request_method())) => http_request_method
}
} = span_record
) do
op = "http.#{span_record.kind}"
client_address = Map.get(span_record.attributes, to_string(ClientAttributes.client_address()))
url_path = Map.get(span_record.attributes, to_string(URLAttributes.url_path()))

description =
to_string(http_request_method) <>
((client_address && " from #{client_address}") || "") <>
((url_path && " #{url_path}") || "")

{op, description}
end

defp get_op_description(
%{attributes: %{unquote(to_string(DBAttributes.db_system())) => _db_system}} =
span_record
) do
db_query_text = Map.get(span_record.attributes, "db.statement")

{"db", db_query_text}
end

defp get_op_description(%{
attributes:
%{unquote(to_string(MessagingAttributes.messaging_system())) => :oban} = attributes
}) do
{"queue.process", attributes["oban.job.worker"]}
end

defp get_op_description(span_record) do
{span_record.name, span_record.name}
end

defp build_span(span_record) do
{op, description} = get_op_description(span_record)

%Span{
op: op,
description: description,
start_timestamp: span_record.start_time,
timestamp: span_record.end_time,
trace_id: span_record.trace_id,
span_id: span_record.span_id,
parent_span_id: span_record.parent_span_id,
origin: span_record.origin,
data: Map.put(span_record.attributes, "otel.kind", span_record.kind),
status: span_status(span_record)
}
end

defp span_status(%{
attributes: %{
unquote(to_string(HTTPAttributes.http_response_status_code())) =>
http_response_status_code
}
}) do
to_status(http_response_status_code)
end

defp span_status(_span_record), do: nil

# WebSocket upgrade spans doesn't have a HTTP status
defp to_status(nil), do: nil

defp to_status(status) when status in 200..299, do: "ok"

for {status, string} <- %{
400 => "invalid_argument",
401 => "unauthenticated",
403 => "permission_denied",
404 => "not_found",
409 => "already_exists",
429 => "resource_exhausted",
499 => "cancelled",
500 => "internal_error",
501 => "unimplemented",
503 => "unavailable",
504 => "deadline_exceeded"
} do
defp to_status(unquote(status)), do: unquote(string)
end

defp to_status(_any), do: "unknown_error"
end
70 changes: 70 additions & 0 deletions lib/sentry/opentelemetry/span_record.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Sentry.OpenTelemetry.SpanRecord do
require Record
require OpenTelemetry

@fields Record.extract(:span, from_lib: "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: normalize_attributes(attributes)
)
|> Map.new()

struct(__MODULE__, attrs)
end

defp normalize_attributes(attributes) do
Enum.map(attributes, fn {key, value} ->
{to_string(key), value}
end)
|> Map.new()
end

defp cast_span_id(nil), do: nil
defp cast_span_id(:undefined), do: nil
defp cast_span_id(span_id), do: bytes_to_hex(span_id, 16)

defp cast_trace_id(trace_id), do: bytes_to_hex(trace_id, 32)

defp cast_timestamp(:undefined), do: nil
defp cast_timestamp(nil), do: nil

defp cast_timestamp(timestamp) do
nano_timestamp = OpenTelemetry.timestamp_to_nano(timestamp)
{:ok, datetime} = DateTime.from_unix(div(nano_timestamp, 1_000_000), :millisecond)

DateTime.to_iso8601(datetime)
end

defp bytes_to_hex(bytes, length) do
case(:otel_utils.format_binary_string("~#{length}.16.0b", [bytes])) do
{:ok, result} -> result
{:error, _} -> raise "Failed to convert bytes to hex: #{inspect(bytes)}"
end
end
end
137 changes: 137 additions & 0 deletions lib/sentry/opentelemetry/span_storage.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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__, opts, name: name)
end

@impl true
def init(opts) do
_table =
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:named_table, :public, :bag])
end

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, stored_at}})
_ -> :ok
end
end

def store_span(span_data) do
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, _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, _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, stored_at}})

_ ->
:ets.delete(@table, {:root_span, span_data.span_id})
: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, stored_at}} ->
if span.span_id == span_data.span_id do
:ets.delete_object(@table, {parent_id, {span, stored_at}})
:ets.insert(@table, {span_data.parent_span_id, {span_data, stored_at}})
end
end)
end

:ok
end

def remove_span(span_id) do
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
: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
60 changes: 36 additions & 24 deletions lib/sentry/options.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Sentry.Options do
@moduledoc false

@send_event_opts_schema_as_keyword [
@common_opts_schema_as_keyword [
result: [
type: {:in, [:sync, :none]},
doc: """
@@ -15,29 +15,6 @@ defmodule Sentry.Options do
call ends up being successful or not.
"""
],
sample_rate: [
type: :float,
doc: """
Same as the global `:sample_rate` configuration, but applied only to
this call. See the module documentation. *Available since v10.0.0*.
"""
],
before_send: [
type: {:or, [{:fun, 1}, {:tuple, [:atom, :atom]}]},
type_doc: "`t:before_send_event_callback/0`",
doc: """
Same as the global `:before_send` configuration, but
applied only to this call. See the module documentation. *Available since v10.0.0*.
"""
],
after_send_event: [
type: {:or, [{:fun, 2}, {:tuple, [:atom, :atom]}]},
type_doc: "`t:after_send_event_callback/0`",
doc: """
Same as the global `:after_send_event` configuration, but
applied only to this call. See the module documentation. *Available since v10.0.0*.
"""
],
client: [
type: :atom,
type_doc: "`t:module/0`",
@@ -54,6 +31,32 @@ defmodule Sentry.Options do
]
]

@send_event_opts_schema_as_keyword Keyword.merge(@common_opts_schema_as_keyword,
sample_rate: [
type: :float,
doc: """
Same as the global `:sample_rate` configuration, but applied only to
this call. See the module documentation. *Available since v10.0.0*.
"""
],
before_send: [
type: {:or, [{:fun, 1}, {:tuple, [:atom, :atom]}]},
type_doc: "`t:before_send_event_callback/0`",
doc: """
Same as the global `:before_send` configuration, but
applied only to this call. See the module documentation. *Available since v10.0.0*.
"""
],
after_send_event: [
type: {:or, [{:fun, 2}, {:tuple, [:atom, :atom]}]},
type_doc: "`t:after_send_event_callback/0`",
doc: """
Same as the global `:after_send_event` configuration, but
applied only to this call. See the module documentation. *Available since v10.0.0*.
"""
]
)

@create_event_opts_schema_as_keyword [
exception: [
type: {:custom, Sentry.Event, :__validate_exception__, [:exception]},
@@ -191,6 +194,10 @@ defmodule Sentry.Options do

@create_event_opts_schema NimbleOptions.new!(@create_event_opts_schema_as_keyword)

@send_transaction_opts_schema_as_keyword Keyword.merge(@common_opts_schema_as_keyword, [])

@send_transaction_opts_schema NimbleOptions.new!(@send_transaction_opts_schema_as_keyword)

@spec send_event_schema() :: NimbleOptions.t()
def send_event_schema do
@send_event_opts_schema
@@ -206,6 +213,11 @@ defmodule Sentry.Options do
@create_event_opts_schema
end

@spec send_transaction_schema() :: NimbleOptions.t()
def send_transaction_schema do
@send_transaction_opts_schema
end

@spec docs_for(atom()) :: String.t()
def docs_for(type)

90 changes: 78 additions & 12 deletions lib/sentry/test.ex
Original file line number Diff line number Diff line change
@@ -77,35 +77,54 @@ defmodule Sentry.Test do
@moduledoc since: "10.2.0"

@server __MODULE__.OwnershipServer
@key :events
@events_key :events
@transactions_key :transactions

# Used internally when reporting an event, *before* reporting the actual event.
@doc false
@spec maybe_collect(Sentry.Event.t()) :: :collected | :not_collecting
def maybe_collect(%Sentry.Event{} = event) do
maybe_collect(event, @events_key)
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
maybe_collect(transaction, @transactions_key)
end

@doc false
def maybe_collect(item, collection_key) 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(), @key) do
case NimbleOwnership.fetch_owner(@server, callers(), collection_key) do
{:ok, owner_pid} ->
result =
NimbleOwnership.get_and_update(@server, owner_pid, @key, fn events ->
{:collected, (events || []) ++ [event]}
end)
NimbleOwnership.get_and_update(
@server,
owner_pid,
collection_key,
fn items ->
{:collected, (items || []) ++ [item]}
end
)

case result do
{:ok, :collected} ->
:collected

{:error, error} ->
raise ArgumentError, "cannot collect Sentry reports: #{Exception.message(error)}"
raise ArgumentError,
"cannot collect Sentry #{collection_key}: #{Exception.message(error)}"
end

:error when dsn_set? ->
:not_collecting

# If the :dsn option is not set and we didn't capture the event, it's alright,
# If the :dsn option is not set and we didn't capture the item, it's alright,
# we can just swallow it.
:error ->
:collected
@@ -135,7 +154,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: @events_key)
start_collecting(key: @transactions_key)
end

@doc """
@@ -177,6 +197,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, @events_key)
owner_pid = Keyword.get(options, :owner, self())
cleanup? = Keyword.get(options, :cleanup, true)

@@ -190,7 +211,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 +228,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)

@@ -246,7 +267,7 @@ defmodule Sentry.Test do
@spec allow_sentry_reports(pid(), pid() | (-> pid())) :: :ok
def allow_sentry_reports(owner_pid, pid_to_allow)
when is_pid(owner_pid) and (is_pid(pid_to_allow) or is_function(pid_to_allow, 0)) do
case NimbleOwnership.allow(@server, owner_pid, pid_to_allow, @key) do
case NimbleOwnership.allow(@server, owner_pid, pid_to_allow, @events_key) do
:ok ->
:ok

@@ -281,7 +302,7 @@ defmodule Sentry.Test do
def pop_sentry_reports(owner_pid \\ self()) when is_pid(owner_pid) do
result =
try do
NimbleOwnership.get_and_update(@server, owner_pid, @key, fn
NimbleOwnership.get_and_update(@server, owner_pid, @events_key, fn
nil -> {:not_collecting, []}
events when is_list(events) -> {events, []}
end)
@@ -302,6 +323,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.new(%{span_id: "123", spans: []}))
{: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, @transactions_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
75 changes: 75 additions & 0 deletions lib/sentry/transaction.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule Sentry.Transaction do
@type t() :: %__MODULE__{}

alias Sentry.{Config, UUID}

@enforce_keys ~w(event_id span_id spans)a

defstruct [
:event_id,
:environment,
:span_id,
:transaction,
:transaction_info,
:contexts,
:measurements,
:spans,
type: "transaction"
]

def new(attrs) do
struct!(
__MODULE__,
attrs
|> Map.put(:event_id, UUID.uuid4_hex())
|> Map.put(:environment, Config.environment_name())
)
end

# Used to then encode the returned map to JSON.
@doc false
def to_map(%__MODULE__{} = transaction) do
transaction_attrs =
Map.take(transaction, [
:event_id,
:environment,
:transaction,
:transaction_info,
:contexts,
:measurements,
:type
])

{[root_span], child_spans} = Enum.split_with(transaction.spans, &is_nil(&1.parent_span_id))

root_span
|> Sentry.Span.to_map()
|> Map.put(:spans, Enum.map(child_spans, &Sentry.Span.to_map/1))
|> Map.drop([:description])
|> Map.merge(transaction_attrs)
end
end

defmodule Sentry.Span do
@enforce_keys ~w(span_id trace_id start_timestamp timestamp)a

defstruct [
:trace_id,
:span_id,
:parent_span_id,
:start_timestamp,
:timestamp,
:description,
:op,
:status,
:tags,
:data,
:origin
]

# Used to then encode the returned map to JSON.
@doc false
def to_map(%__MODULE__{} = span) do
Map.from_struct(span)
end
end
22 changes: 21 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}

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,17 @@ defmodule Sentry.Transport.Sender do

{:noreply, state}
end

@impl GenServer
def handle_cast({:send, client, %Transaction{} = transaction}, %__MODULE__{} = state) do
_ =
transaction
|> Envelope.from_transaction()
|> Transport.encode_and_post_envelope(client)

# We sent a transaction, so we can decrease the number of queued transactions.
Transport.SenderPool.decrease_queued_transactions_counter()

{:noreply, state}
end
end
16 changes: 16 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,12 +46,24 @@ 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)
: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)
7 changes: 6 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -111,7 +111,12 @@ 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]},

# Required by Tracing
{:opentelemetry, "~> 1.5"},
{:opentelemetry_api, "~> 1.4"},
{:opentelemetry_semantic_conventions, "~> 1.27"}
]
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.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, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"},
"phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},
58 changes: 58 additions & 0 deletions test/envelope_test.exs
Original file line number Diff line number Diff line change
@@ -113,6 +113,64 @@ 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")

root_span =
%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: nil,
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"
}
}

child_spans =
[
%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.new(%{
span_id: root_span.span_id,
spans: [root_span | child_spans],
transaction: "test-transaction"
})

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"] == root_span.start_timestamp
assert decoded_transaction["timestamp"] == root_span.timestamp

assert [span] = decoded_transaction["spans"]

assert span["start_timestamp"] == List.first(child_spans).start_timestamp
assert span["timestamp"] == List.first(child_spans).timestamp
end
end

test "works with client reports" do
42 changes: 34 additions & 8 deletions test/sentry/client_report/sender_test.exs
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ defmodule Sentry.ClientReportTest do
import Sentry.TestHelpers

alias Sentry.ClientReport.Sender
alias Sentry.Event
alias Sentry.{Event, Transaction, Span}

setup do
original_retries =
@@ -19,6 +19,8 @@ defmodule Sentry.ClientReportTest do
%{bypass: bypass}
end

@span_id Sentry.UUID.uuid4_hex()

describe "record_discarded_events/2 + flushing" do
test "succefully records the discarded event to the client report", %{bypass: bypass} do
start_supervised!({Sender, name: :test_client_report})
@@ -27,24 +29,45 @@ defmodule Sentry.ClientReportTest do
%Event{
event_id: Sentry.UUID.uuid4_hex(),
timestamp: "2024-10-12T13:21:13"
}
},
Transaction.new(%{
span_id: @span_id,
transaction: "test-transaction",
spans: [
%Span{
span_id: @span_id,
trace_id: Sentry.UUID.uuid4_hex(),
start_timestamp: "2024-10-12T13:21:13",
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"} => 1,
{:before_send, "transaction"} => 1
}

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"} => 2,
{:before_send, "transaction"} => 2
}

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,
{:before_send, "transaction"} => 2,
{:event_processor, "error"} => 1,
{:network_error, "error"} => 1
{:event_processor, "transaction"} => 1,
{:network_error, "error"} => 1,
{:network_error, "transaction"} => 1
}

send(Process.whereis(:test_client_report), :send_report)
@@ -56,9 +79,12 @@ 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}
%{"category" => "error", "quantity" => 2, "reason" => "before_send"},
%{"category" => "transaction", "quantity" => 2, "reason" => "before_send"},
%{"category" => "error", "quantity" => 1, "reason" => "event_processor"},
%{"category" => "transaction", "quantity" => 1, "reason" => "event_processor"},
%{"category" => "error", "quantity" => 1, "reason" => "network_error"},
%{"category" => "transaction", "quantity" => 1, "reason" => "network_error"}
]

assert client_report["timestamp"] =~ ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/
19 changes: 19 additions & 0 deletions test/sentry/opentelemetry/sampler_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule Sentry.Opentelemetry.SamplerTest do
use Sentry.Case, async: false

alias Sentry.OpenTelemetry.Sampler

test "drops spans with the given name" do
assert {:drop, [], []} =
Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Stager process", nil, nil,
drop: ["Elixir.Oban.Stager process"]
)
end

test "records and samples spans with the given name" do
assert {:record_and_sample, [], []} =
Sampler.should_sample(nil, nil, nil, "Elixir.Oban.Worker process", nil, nil,
drop: []
)
end
end
133 changes: 133 additions & 0 deletions test/sentry/opentelemetry/span_processor_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
defmodule Sentry.Opentelemetry.SpanProcessorTest do
use Sentry.Case, async: false

import Sentry.TestHelpers

alias Sentry.OpenTelemetry.SpanStorage

setup do
on_exit(fn ->
# Only try to clean up tables if they exist
if :ets.whereis(:span_storage) != :undefined do
:ets.delete_all_objects(:span_storage)
end
end)

:ok
end

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()

transaction_data = Sentry.Transaction.to_map(transaction)

assert transaction_data.event_id
assert transaction_data.environment == "test"
assert transaction_data.type == "transaction"
assert transaction_data.op == "child_instrumented_function_one"
assert transaction_data.transaction_info == %{source: :custom}
assert_valid_iso8601(transaction_data.timestamp)
assert_valid_iso8601(transaction_data.start_timestamp)
assert transaction_data.timestamp > transaction_data.start_timestamp
assert_valid_trace_id(transaction_data.contexts.trace.trace_id)
assert length(transaction_data.spans) == 0
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()

transaction_data = Sentry.Transaction.to_map(transaction)

assert transaction_data.op == "instrumented_function"
assert_valid_iso8601(transaction_data.timestamp)
assert_valid_iso8601(transaction_data.start_timestamp)
assert transaction_data.timestamp > transaction_data.start_timestamp
assert length(transaction_data.spans) == 2

[child_span_one, child_span_two] = transaction_data.spans
assert child_span_one.op == "child_instrumented_function_one"
assert child_span_two.op == "child_instrumented_function_two"
assert child_span_one.parent_span_id == transaction.contexts.trace.span_id
assert child_span_two.parent_span_id == transaction.contexts.trace.span_id

assert_valid_iso8601(child_span_one.timestamp)
assert_valid_iso8601(child_span_one.start_timestamp)
assert_valid_iso8601(child_span_two.timestamp)
assert_valid_iso8601(child_span_two.start_timestamp)

assert child_span_one.timestamp > child_span_one.start_timestamp
assert child_span_two.timestamp > child_span_two.start_timestamp
assert transaction_data.timestamp >= child_span_one.timestamp
assert transaction_data.timestamp >= child_span_two.timestamp
assert transaction_data.start_timestamp <= child_span_one.start_timestamp
assert transaction_data.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

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 == 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
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
361 changes: 361 additions & 0 deletions test/sentry/opentelemetry/span_storage_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,361 @@
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

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)

assert root_span == SpanStorage.get_root_span("root123")
assert length(SpanStorage.get_child_spans("root123")) == 2

SpanStorage.remove_span("root123")

assert nil == SpanStorage.get_root_span("root123")
assert [] == SpanStorage.get_child_spans("root123")
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

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
50 changes: 50 additions & 0 deletions test/sentry_test.exs
Original file line number Diff line number Diff line change
@@ -235,4 +235,54 @@ defmodule SentryTest do
assert Sentry.get_dsn() == random_dsn
end
end

describe "send_transaction/2" do
setup do
transaction =
Sentry.Transaction.new(%{
span_id: "root-span",
transaction: "test-transaction",
spans: [
%Sentry.Span{
span_id: "root-span",
trace_id: "trace-id",
start_timestamp: 1_234_567_891.123_456,
timestamp: 1_234_567_891.123_456
}
]
})

{: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 "validates options", %{transaction: transaction} do
assert_raise NimbleOptions.ValidationError, fn ->
Sentry.send_transaction(transaction, client: "oops")
end
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
6 changes: 6 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,11 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []}

config :opentelemetry,
sampler: {Sentry.OpenTelemetry.Sampler, [drop: ["Elixir.Oban.Stager process"]]}

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
21 changes: 21 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.
#
@@ -73,3 +78,19 @@ 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

config :phoenix_app, Oban,
repo: PhoenixApp.Repo,
engine: Oban.Engines.Lite,
queues: [default: 10, background: 5]
20 changes: 15 additions & 5 deletions test_integrations/phoenix_app/config/test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import Config

# Configure your database
config :phoenix_app, PhoenixApp.Repo,
adapter: Ecto.Adapters.SQLite3,
pool: Ecto.Adapters.SQL.Sandbox,
database: "db/test.sqlite3"

# We don't run a server during test. If one is required,
# you can enable the server option below.
config :phoenix_app, PhoenixAppWeb.Endpoint,
@@ -24,9 +30,13 @@ 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

config :phoenix_app, Oban,
repo: PhoenixApp.Repo,
engine: Oban.Engines.Lite,
queues: [default: 10, background: 5]
104 changes: 104 additions & 0 deletions test_integrations/phoenix_app/lib/phoenix_app/accounts.ex
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions test_integrations/phoenix_app/lib/phoenix_app/accounts/user.ex
Original file line number Diff line number Diff line change
@@ -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
33 changes: 25 additions & 8 deletions test_integrations/phoenix_app/lib/phoenix_app/application.ex
Original file line number Diff line number Diff line change
@@ -7,14 +7,28 @@ defmodule PhoenixApp.Application do

@impl true
def start(_type, _args) do
:ok = Application.ensure_started(:inets)

:logger.add_handler(:my_sentry_handler, Sentry.LoggerHandler, %{
config: %{metadata: [:file, :line]}
})

# OpentelemetryBandit.setup()
OpentelemetryPhoenix.setup(adapter: :bandit)
OpentelemetryOban.setup()
OpentelemetryEcto.setup([:phoenix_app, :repo], db_statement: :enabled)

children = [
PhoenixAppWeb.Telemetry,
PhoenixApp.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:phoenix_app, :ecto_repos), skip: skip_migrations?()},
{DNSCluster, query: Application.get_env(:phoenix_app, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: PhoenixApp.PubSub},
# Start the Finch HTTP client for sending emails
{Finch, name: PhoenixApp.Finch},
# Start a worker by calling: PhoenixApp.Worker.start_link(arg)
# {PhoenixApp.Worker, arg},
# Start Oban
{Oban, Application.fetch_env!(:phoenix_app, Oban)},
# Start to serve requests, typically the last entry
PhoenixAppWeb.Endpoint
]
@@ -25,12 +39,15 @@ defmodule PhoenixApp.Application do
Supervisor.start_link(children, opts)
end

# TODO: Uncomment if we ever move the endpoint from test/support to the phoenix_app dir
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
# @impl true
# def config_change(changed, _new, removed) do
# PhoenixAppWeb.Endpoint.config_change(changed, removed)
# :ok
# end
@impl true
def config_change(changed, _new, removed) do
PhoenixAppWeb.Endpoint.config_change(changed, removed)
:ok
end

defp skip_migrations?() do
System.get_env("RELEASE_NAME") != nil
end
end
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
Original file line number Diff line number Diff line change
@@ -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
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,
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
defmodule PhoenixAppWeb.TestWorkerLive do
use PhoenixAppWeb, :live_view

alias PhoenixApp.Workers.TestWorker

@impl true
def mount(_params, _session, socket) do
socket =
assign(socket,
form: to_form(%{"sleep_time" => 1000, "should_fail" => false, "queue" => "default"}),
auto_form: to_form(%{"job_count" => 5}),
jobs: list_jobs()
)

if connected?(socket) do
# Poll for job updates every second
:timer.send_interval(1000, self(), :update_jobs)
end

{:ok, socket}
end

@impl true
def handle_event("schedule", %{"test_job" => params}, socket) do
sleep_time = String.to_integer(params["sleep_time"])
should_fail = params["should_fail"] == "true"
queue = params["queue"]

case schedule_job(sleep_time, should_fail, queue) do
{:ok, _job} ->
{:noreply,
socket
|> put_flash(:info, "Job scheduled successfully!")
|> assign(jobs: list_jobs())}

{:error, changeset} ->
{:noreply,
socket
|> put_flash(:error, "Error scheduling job: #{inspect(changeset.errors)}")}
end
end

@impl true
def handle_event("auto_schedule", %{"auto" => %{"job_count" => count}}, socket) do
job_count = String.to_integer(count)

results =
Enum.map(1..job_count, fn _ ->
sleep_time = Enum.random(500..5000)
should_fail = Enum.random([true, false])
queue = Enum.random(["default", "background"])

schedule_job(sleep_time, should_fail, queue)
end)

failed_count = Enum.count(results, &match?({:error, _}, &1))
success_count = job_count - failed_count

socket =
socket
|> put_flash(:info, "Scheduled #{success_count} jobs successfully!")
|> assign(jobs: list_jobs())

if failed_count > 0 do
socket = put_flash(socket, :error, "Failed to schedule #{failed_count} jobs")
{:noreply, socket}
else
{:noreply, socket}
end
end

@impl true
def handle_info(:update_jobs, socket) do
{:noreply, assign(socket, jobs: list_jobs())}
end

defp schedule_job(sleep_time, should_fail, queue) do
TestWorker.new(
%{"sleep_time" => sleep_time, "should_fail" => should_fail},
queue: queue
)
|> Oban.insert()
end

defp list_jobs do
import Ecto.Query

Oban.Job
|> where([j], j.worker == "PhoenixApp.Workers.TestWorker")
|> order_by([j], desc: j.inserted_at)
|> limit(10)
|> PhoenixApp.Repo.all()
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<div class="mx-auto max-w-2xl">
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900">Schedule Test Worker</h3>

<div class="mt-5">
<.form for={@form} phx-submit="schedule" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700">Sleep Time (ms)</label>
<div class="mt-1">
<input type="number" name="test_job[sleep_time]" value="1000" min="0"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
</div>
</div>

<div>
<label class="block text-sm font-medium text-gray-700">Queue</label>
<select name="test_job[queue]" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="default">default</option>
<option value="background">background</option>
</select>
</div>

<div class="relative flex items-start">
<div class="flex h-6 items-center">
<input type="checkbox" name="test_job[should_fail]" value="true"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" />
</div>
<div class="ml-3 text-sm leading-6">
<label class="font-medium text-gray-900">Should Fail</label>
</div>
</div>

<div>
<button type="submit" class="inline-flex justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Schedule Job
</button>
</div>
</.form>
</div>
</div>
</div>

<div class="mt-8 bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900">Auto Schedule Multiple Jobs</h3>

<div class="mt-5">
<.form for={@auto_form} phx-submit="auto_schedule" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700">Number of Jobs</label>
<div class="mt-1">
<input type="number"
name="auto[job_count]"
value="5"
min="1"
max="100"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
</div>
<p class="mt-2 text-sm text-gray-500">
Jobs will be created with random sleep times (500-5000ms), random queues, and random failure states.
</p>
</div>

<div>
<button type="submit" class="inline-flex justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Auto Schedule Jobs
</button>
</div>
</.form>
</div>
</div>
</div>

<div class="mt-8">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Recent Jobs</h3>

<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">ID</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Queue</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">State</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Attempt</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Args</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<%= for job <- @jobs do %>
<tr>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= job.id %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= job.queue %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= job.state %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= job.attempt %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= inspect(job.args) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
defmodule PhoenixAppWeb.UserLive.FormComponent do
use PhoenixAppWeb, :live_component

alias PhoenixApp.Accounts

@impl true
def render(assigns) do
~H"""
<div>
<.header>
<%= @title %>
<:subtitle>Use this form to manage user records in your database.</:subtitle>
</.header>
<.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</.button>
</:actions>
</.simple_form>
</div>
"""
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<.header>
Listing Users
<:actions>
<.link patch={~p"/users/new"}>
<.button>New User</.button>
</.link>
</:actions>
</.header>

<.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>
<:col :let={{_id, user}} label="Age"><%= user.age %></:col>
<:action :let={{_id, user}}>
<div class="sr-only">
<.link navigate={~p"/users/#{user}"}>Show</.link>
</div>
<.link patch={~p"/users/#{user}/edit"}>Edit</.link>
</:action>
<:action :let={{id, user}}>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>

<.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"}
/>
</.modal>
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<.header>
User <%= @user.id %>
<:subtitle>This is a user record from your database.</:subtitle>
<:actions>
<.link patch={~p"/users/#{@user}/show/edit"} phx-click={JS.push_focus()}>
<.button>Edit user</.button>
</.link>
</:actions>
</.header>

<.list>
<:item title="Name"><%= @user.name %></:item>
<:item title="Age"><%= @user.age %></:item>
</.list>

<.back navigate={~p"/users"}>Back to users</.back>

<.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}"}
/>
</.modal>
10 changes: 10 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,16 @@ defmodule PhoenixAppWeb.Router do

get "/", PageController, :home
get "/exception", PageController, :exception
get "/transaction", PageController, :transaction

live "/test-worker", TestWorkerLive

live "/users", UserLive.Index, :index
live "/users/new", UserLive.Index, :new
live "/users/:id/edit", UserLive.Index, :edit

live "/users/:id", UserLive.Show, :show
live "/users/:id/show/edit", UserLive.Show, :edit
end

# Other scopes may use custom stacks.
27 changes: 24 additions & 3 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},
{:phoenix_ecto, "~> 4.6", optional: true},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.1.1",
sparse: "optimized",
app: false,
compile: false,
depth: 1},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
@@ -53,9 +64,19 @@ defmodule PhoenixApp.MixProject do
{:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.5"},
{:bypass, "~> 2.1", only: :test},
{:hackney, "~> 1.18", only: :test},

{:sentry, path: "../.."}
{:opentelemetry, "~> 1.5"},
{:opentelemetry_api, "~> 1.4"},
{:opentelemetry_phoenix, "~> 2.0"},
{:opentelemetry_semantic_conventions, "~> 1.27"},
# TODO: Update once merged
{:opentelemetry_oban, "~> 1.1",
github: "danschultzer/opentelemetry-erlang-contrib",
branch: "oban-v1.27-semantics",
sparse: "instrumentation/opentelemetry_oban"},
{:opentelemetry_ecto, "~> 1.2"},
{:sentry, path: "../.."},
{:hackney, "~> 1.18"},
{:oban, "~> 2.10"}
]
end

23 changes: 21 additions & 2 deletions test_integrations/phoenix_app/mix.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule PhoenixApp.Repo.Migrations.CreateUsers do
use Ecto.Migration

def change do
create table(:users) do
add :name, :string
add :age, :integer

timestamps(type: :utc_datetime)
end
end
end
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions test_integrations/phoenix_app/test/phoenix_app/oban_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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: :custom}

trace = transaction.contexts.trace
assert trace.origin == "opentelemetry_oban"
assert trace.op == "queue.process"
assert trace.description == "Sentry.Integrations.Phoenix.ObanTest.TestWorker"
assert trace.data["oban.job.job_id"]
assert trace.data["messaging.destination"] == "default"
assert trace.data["oban.job.attempt"] == 1

assert [span] = transaction.spans

assert span.op == "queue.process"
assert span.description == "Sentry.Integrations.Phoenix.ObanTest.TestWorker"
assert span.data["oban.job.job_id"]
assert span.data["messaging.destination"] == "default"
assert span.data["oban.job.attempt"] == 1
end
end
28 changes: 28 additions & 0 deletions test_integrations/phoenix_app/test/phoenix_app/repo_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule PhoenixApp.RepoTest do
use PhoenixApp.DataCase

alias PhoenixApp.{Repo, Accounts.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: :custom}
assert transaction.contexts.trace.op == "db"
assert String.starts_with?(transaction.contexts.trace.description, "SELECT")
assert transaction.contexts.trace.data["db.system"] == :sqlite
end
end
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,76 @@
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
# TODO: Wrap this in a transaction that the web server usually
# would wrap it in.
get(conn, ~p"/transaction")

transactions = Sentry.Test.pop_sentry_transactions()

assert length(transactions) == 1

assert [transaction] = transactions

assert transaction.transaction == "test_span"
assert transaction.transaction_info == %{source: :custom}

trace = transaction.contexts.trace
assert trace.origin == "phoenix_app"
assert trace.op == "test_span"
assert trace.data == %{}

assert [span] = transaction.spans

assert span.op == "test_span"
assert span.trace_id == trace.trace_id
refute span.parent_span_id
end

test "GET /users", %{conn: conn} do
get(conn, ~p"/users")

transactions = Sentry.Test.pop_sentry_transactions()

assert length(transactions) == 2

assert [mount_transaction, handle_params_transaction] = transactions

assert mount_transaction.transaction == "PhoenixAppWeb.UserLive.Index.mount"
assert mount_transaction.transaction_info == %{source: :custom}

trace = mount_transaction.contexts.trace
assert trace.origin == "opentelemetry_phoenix"
assert trace.op == "PhoenixAppWeb.UserLive.Index.mount"
assert trace.data == %{}

assert [span_mount, span_ecto] = mount_transaction.spans

assert span_mount.op == "PhoenixAppWeb.UserLive.Index.mount"
assert span_mount.description == "PhoenixAppWeb.UserLive.Index.mount"

assert span_ecto.op == "db"
assert span_ecto.description == "SELECT u0.\"id\", u0.\"name\", u0.\"age\", u0.\"inserted_at\", u0.\"updated_at\" FROM \"users\" AS u0"

assert handle_params_transaction.transaction == "PhoenixAppWeb.UserLive.Index.handle_params"
assert handle_params_transaction.transaction_info == %{source: :custom}

trace = handle_params_transaction.contexts.trace
assert trace.origin == "opentelemetry_phoenix"
assert trace.op == "PhoenixAppWeb.UserLive.Index.handle_params"
assert trace.data == %{}

assert [span_handle_params] = handle_params_transaction.spans

assert span_handle_params.op == "PhoenixAppWeb.UserLive.Index.handle_params"
assert span_handle_params.description == "PhoenixAppWeb.UserLive.Index.handle_params"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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&#39;t be blank"

assert index_live
|> form("#user-form", user: @create_attrs)
|> render_submit()

assert_patch(index_live, ~p"/users")

html = render(index_live)
assert html =~ "User created successfully"
assert html =~ "some name"

transactions = Sentry.Test.pop_sentry_transactions()

transaction_save =
Enum.find(transactions, fn transaction ->
transaction.transaction == "PhoenixAppWeb.UserLive.Index.handle_event#save"
end)

assert transaction_save.transaction == "PhoenixAppWeb.UserLive.Index.handle_event#save"
assert transaction_save.transaction_info.source == :custom
assert transaction_save.contexts.trace.op == "PhoenixAppWeb.UserLive.Index.handle_event#save"
assert transaction_save.contexts.trace.origin == "opentelemetry_phoenix"

assert length(transaction_save.spans) == 2
assert [_span_1, span_2] = transaction_save.spans
assert span_2.op == "db"
assert span_2.description =~ "INSERT INTO \"users\""
assert span_2.data["db.system"] == :sqlite
assert span_2.data["db.type"] == :sql
assert span_2.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&#39;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&#39;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
26 changes: 13 additions & 13 deletions test_integrations/phoenix_app/test/support/data_case.ex
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion test_integrations/phoenix_app/test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
ExUnit.start()
# Ecto.Adapters.SQL.Sandbox.mode(PhoenixApp.Repo, :manual)
Ecto.Adapters.SQL.Sandbox.mode(PhoenixApp.Repo, :manual)