diff --git a/.gitignore b/.gitignore index 39814143..4b41496f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test_integrations/phoenix_app/db test_integrations/*/_build test_integrations/*/deps +test_integrations/*/test-results/ diff --git a/lib/sentry.ex b/lib/sentry.ex index 7a8bd649..110c2e25 100644 --- a/lib/sentry.ex +++ b/lib/sentry.ex @@ -14,7 +14,8 @@ defmodule Sentry do * Automatically for Plug/Phoenix applications — see the [*Setup with Plug and Phoenix* guide](setup-with-plug-and-phoenix.html), and the - `Sentry.PlugCapture` and `Sentry.PlugContext` modules. + `Sentry.PlugCapture`, `Sentry.PlugContext`, `Sentry.Plug.LiveViewContext`, and + `Sentry.Phoenix.LiveViewTracing`. * Through integrations for various ecosystem tools, like [Oban](oban-integration.html) or [Quantum](quantum-integration.html). diff --git a/lib/sentry/opentelemetry/propagator.ex b/lib/sentry/opentelemetry/propagator.ex new file mode 100644 index 00000000..2b126e0d --- /dev/null +++ b/lib/sentry/opentelemetry/propagator.ex @@ -0,0 +1,153 @@ +if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + defmodule Sentry.OpenTelemetry.Propagator do + @moduledoc """ + OpenTelemetry propagator for Sentry distributed tracing. + + This propagator implements the `sentry-trace` and `sentry-baggage` header propagation + to enable distributed tracing across service boundaries. It follows the W3C Trace Context. + + ## Usage + + Add the propagator to your OpenTelemetry configuration: + + config :opentelemetry, + text_map_propagators: [ + :trace_context, + :baggage, + Sentry.OpenTelemetry.Propagator + ] + + This will enable automatic propagation of Sentry trace context in HTTP headers. + """ + + import Bitwise + + require Record + require OpenTelemetry.Tracer, as: Tracer + + @behaviour :otel_propagator_text_map + + @fields Record.extract(:span_ctx, from_lib: "opentelemetry_api/include/opentelemetry.hrl") + Record.defrecordp(:span_ctx, @fields) + + @sentry_trace_key "sentry-trace" + @sentry_baggage_key "baggage" + @sentry_trace_ctx_key :"sentry-trace" + @sentry_baggage_ctx_key :"sentry-baggage" + + @impl true + def fields(_opts) do + [@sentry_trace_key, @sentry_baggage_key] + end + + @impl true + def inject(ctx, carrier, setter, _opts) do + case Tracer.current_span_ctx(ctx) do + span_ctx(trace_id: tid, span_id: sid, trace_flags: flags) when tid != 0 and sid != 0 -> + sentry_trace_header = encode_sentry_trace({tid, sid, flags}) + carrier = setter.(@sentry_trace_key, sentry_trace_header, carrier) + + # Inject baggage if it exists in the context + # Note: :otel_ctx.get_value/2 returns the key itself if value not found + baggage_value = :otel_ctx.get_value(ctx, @sentry_baggage_ctx_key, :not_found) + + if is_binary(baggage_value) and baggage_value != :not_found do + setter.(@sentry_baggage_key, baggage_value, carrier) + else + carrier + end + + _ -> + carrier + end + end + + @impl true + def extract(ctx, carrier, _keys_fun, getter, _opts) do + case getter.(@sentry_trace_key, carrier) do + :undefined -> + ctx + + header when is_binary(header) -> + case decode_sentry_trace(header) do + {:ok, {trace_hex, span_hex, sampled}} -> + ctx = + ctx + |> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled}) + |> maybe_set_baggage(getter.(@sentry_baggage_key, carrier)) + + trace_id = hex_to_int(trace_hex) + span_id = hex_to_int(span_hex) + + # Always use sampled (1) to simulate a sampled trace on the OTel side + trace_flags = 1 + + remote_span_ctx = + :otel_tracer.from_remote_span(trace_id, span_id, trace_flags) + + Tracer.set_current_span(ctx, remote_span_ctx) + + {:error, _reason} -> + # Invalid header format, skip propagation + ctx + end + + _ -> + ctx + end + end + + # Encode trace ID, span ID, and sampled flag to sentry-trace header format + # Format: {trace_id}-{span_id}-{sampled} + defp encode_sentry_trace({trace_id_int, span_id_int, trace_flags}) do + sampled = if (trace_flags &&& 1) == 1, do: "1", else: "0" + int_to_hex(trace_id_int, 16) <> "-" <> int_to_hex(span_id_int, 8) <> "-" <> sampled + end + + # Decode sentry-trace header + # Format: {trace_id}-{span_id}-{sampled} or {trace_id}-{span_id} + defp decode_sentry_trace( + <> + ) do + {:ok, {trace_hex, span_hex, sampled == "1"}} + end + + defp decode_sentry_trace(<>) do + {:ok, {trace_hex, span_hex, false}} + end + + defp decode_sentry_trace(_invalid) do + {:error, :invalid_format} + end + + defp maybe_set_baggage(ctx, :undefined), do: ctx + defp maybe_set_baggage(ctx, ""), do: ctx + defp maybe_set_baggage(ctx, nil), do: ctx + + defp maybe_set_baggage(ctx, baggage) when is_binary(baggage) do + :otel_ctx.set_value(ctx, @sentry_baggage_ctx_key, baggage) + end + + # Convert hex string to integer + defp hex_to_int(hex) do + hex + |> Base.decode16!(case: :mixed) + |> :binary.decode_unsigned() + end + + # Convert integer to hex string with padding + defp int_to_hex(value, num_bytes) do + value + |> :binary.encode_unsigned() + |> bin_pad_left(num_bytes) + |> Base.encode16(case: :lower) + end + + # Pad binary to specified number of bytes + defp bin_pad_left(bin, total_bytes) do + missing = total_bytes - byte_size(bin) + if missing > 0, do: :binary.copy(<<0>>, missing) <> bin, else: bin + end + end +end diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index c0f81203..eea43096 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -27,8 +27,20 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do SpanStorage.store_span(span_record) - if span_record.parent_span_id == nil do - child_span_records = SpanStorage.get_child_spans(span_record.span_id) + # Check if this is a root span (no parent) or a transaction root (HTTP server request span) + # HTTP server request spans should be treated as transaction roots even when they have + # an external parent span ID (from distributed tracing) + is_transaction_root = + span_record.parent_span_id == nil or + is_http_server_request_span?(span_record) or + is_live_view_server_span?(span_record) + + if is_transaction_root do + child_span_records = + span_record.span_id + |> SpanStorage.get_child_spans() + |> maybe_add_remote_children(span_record) + transaction = build_transaction(span_record, child_span_records) result = @@ -47,8 +59,18 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do {:error, :invalid_span} end + # Clean up: remove the transaction root span and all its children + # Note: For distributed tracing, the transaction root span may have been stored + # as a child span (with a remote parent_span_id). In that case, we need to also + # remove it from the child spans, not just look for it as a root span. :ok = SpanStorage.remove_root_span(span_record.span_id) + if span_record.parent_span_id != nil do + # This span was stored as a child because it has a remote parent (distributed tracing). + # We need to explicitly remove it from the child spans storage. + :ok = SpanStorage.remove_child_span(span_record.parent_span_id, span_record.span_id) + end + result else true @@ -60,6 +82,75 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do :ok end + # Helper function to detect if a span represents an HTTP server request + # that should be treated as a transaction root for distributed tracing + defp is_http_server_request_span?(%{kind: kind, attributes: attributes}) do + kind == :server and + Map.has_key?(attributes, to_string(HTTPAttributes.http_request_method())) + end + + defp is_live_view_server_span?(%{kind: :server, origin: origin, name: name}) + when origin in ["opentelemetry_phoenix", :opentelemetry_phoenix] do + String.ends_with?(name, ".mount") or + String.contains?(name, ".handle_params") or + String.contains?(name, ".handle_event") + end + + defp is_live_view_server_span?(_span_record), do: false + + defp maybe_add_remote_children(child_span_records, %{parent_span_id: nil}) do + child_span_records + end + + defp maybe_add_remote_children(child_span_records, span_record) do + if is_live_view_server_span?(span_record) do + existing_ids = MapSet.new(child_span_records, & &1.span_id) + + adopted_children = + span_record.parent_span_id + |> SpanStorage.get_child_spans() + |> Enum.filter(&eligible_for_adoption?(&1, span_record, existing_ids)) + |> Enum.map(&%{&1 | parent_span_id: span_record.span_id}) + + Enum.each(adopted_children, fn child -> + :ok = SpanStorage.remove_child_span(span_record.parent_span_id, child.span_id) + end) + + child_span_records ++ adopted_children + else + child_span_records + end + end + + defp eligible_for_adoption?(child, span_record, existing_ids) do + not MapSet.member?(existing_ids, child.span_id) and + child.parent_span_id == span_record.parent_span_id and + child.trace_id == span_record.trace_id and + child.kind != :server and + occurs_within_span?(child, span_record) + end + + defp occurs_within_span?(child, parent) do + with {:ok, parent_start} <- parse_datetime(parent.start_time), + {:ok, parent_end} <- parse_datetime(parent.end_time), + {:ok, child_start} <- parse_datetime(child.start_time), + {:ok, child_end} <- parse_datetime(child.end_time) do + DateTime.compare(child_start, parent_start) != :lt and + DateTime.compare(child_end, parent_end) != :gt + else + _ -> true + end + end + + defp parse_datetime(nil), do: :error + + defp parse_datetime(timestamp) do + case DateTime.from_iso8601(timestamp) do + {:ok, datetime, _offset} -> {:ok, datetime} + {:error, _} -> :error + end + 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)) diff --git a/lib/sentry/opentelemetry/span_storage.ex b/lib/sentry/opentelemetry/span_storage.ex index 4b69b936..cc2ff71b 100644 --- a/lib/sentry/opentelemetry/span_storage.ex +++ b/lib/sentry/opentelemetry/span_storage.ex @@ -121,6 +121,16 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do :ok end + @spec remove_child_span(String.t(), String.t(), keyword()) :: :ok + def remove_child_span(parent_span_id, span_id, opts \\ []) do + table_name = Keyword.get(opts, :table_name, default_table_name()) + key = {:child_span, parent_span_id, span_id} + + :ets.delete(table_name, key) + + :ok + end + defp schedule_cleanup(interval) do Process.send_after(self(), :cleanup_stale_spans, interval) end diff --git a/lib/sentry/phoenix/live_view_tracing.ex b/lib/sentry/phoenix/live_view_tracing.ex new file mode 100644 index 00000000..152066a3 --- /dev/null +++ b/lib/sentry/phoenix/live_view_tracing.ex @@ -0,0 +1,67 @@ +if Code.ensure_loaded?(Phoenix.LiveView) and + Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + defmodule Sentry.Phoenix.LiveViewTracing do + @moduledoc """ + LiveView hook that attaches the propagated OpenTelemetry context saved by `Sentry.Plug.LiveViewContext`. + + Configure your router with `on_mount {Sentry.Phoenix.LiveViewTracing, :attach}` so that the LiveView + process can deserialize the carrier written to the session, attach it to the process, and inherit the + incoming trace ID for spans emitted by `opentelemetry_phoenix`. + + If the session key is missing (for example, when a LiveView spawns another LiveView), the hook falls + back to `OpentelemetryProcessPropagator.fetch_parent_ctx/0` when the dependency is available. + """ + + alias OpenTelemetry.Ctx + alias Sentry.OpenTelemetry.Propagator + + @session_key "__sentry_live_view_context__" + @context_token_key :sentry_live_view_tracing_token + + @doc """ + Attach the propagated context to a LiveView process. + """ + @spec on_mount(atom(), map(), map(), Phoenix.LiveView.Socket.t()) :: + {:cont, Phoenix.LiveView.Socket.t()} + def on_mount(:attach, _params, session, socket) do + {:cont, maybe_attach_context(socket, session)} + end + + defp maybe_attach_context(socket, session) do + case Map.get(session, @session_key) do + carrier when is_map(carrier) and carrier != %{} -> + attach_context(socket, extract_context(carrier)) + + _ -> + attach_context(socket, fetch_parent_context()) + end + end + + defp extract_context(carrier) do + ctx = Ctx.get_current() + keys_fun = fn _ -> Map.keys(carrier) end + getter = fn key, _ -> Map.get(carrier, key, :undefined) end + + Propagator.extract(ctx, carrier, keys_fun, getter, []) + end + + defp fetch_parent_context do + module = :OpentelemetryProcessPropagator + + if Code.ensure_loaded?(module) do + apply(module, :fetch_parent_ctx, []) + else + :undefined + end + end + + defp attach_context(socket, ctx) when ctx in [:undefined, nil] do + socket + end + + defp attach_context(socket, ctx) do + token = Ctx.attach(ctx) + %{socket | private: Map.put(socket.private, @context_token_key, token)} + end + end +end diff --git a/lib/sentry/plug/live_view_context.ex b/lib/sentry/plug/live_view_context.ex new file mode 100644 index 00000000..ffe3b066 --- /dev/null +++ b/lib/sentry/plug/live_view_context.ex @@ -0,0 +1,119 @@ +if Code.ensure_loaded?(Plug) and Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + defmodule Sentry.Plug.LiveViewContext do + @moduledoc """ + Plug that captures the current OpenTelemetry context and embeds it into the LiveView session. + + When placed before your LiveView routes, it serializes the currently attached trace (`sentry-trace` + and `baggage`) via `Sentry.OpenTelemetry.Propagator`. The serialized carrier is stored under + `"__sentry_live_view_context__"` in the session so the LiveView process can pick it up, and a + companion cleanup plug removes the key after the response has been committed. + """ + + @behaviour Plug + + alias OpenTelemetry.Ctx + alias OpenTelemetry.Tracer + alias Sentry.OpenTelemetry.Propagator + + @session_key "__sentry_live_view_context__" + @sentry_trace_header "sentry-trace" + @ctx_token_key :sentry_live_view_ctx_token + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + {conn, carrier} = ensure_trace_carrier(conn) + store_session(conn, carrier) + end + + defp store_session(conn, carrier) do + if Map.has_key?(carrier, @sentry_trace_header) do + conn + |> Plug.Conn.fetch_session() + |> Plug.Conn.put_session(@session_key, carrier) + else + conn + end + end + + defp ensure_trace_carrier(conn) do + ctx_carrier = build_carrier_from_ctx() + + if Map.has_key?(ctx_carrier, @sentry_trace_header) do + {conn, ctx_carrier} + else + header_carrier = build_carrier_from_headers(conn) + + if Map.has_key?(header_carrier, @sentry_trace_header) do + attach_conn = attach_from_headers(conn, header_carrier) + {attach_conn, header_carrier} + else + {conn, header_carrier} + end + end + end + + defp build_carrier_from_ctx do + ctx = Ctx.get_current() + setter = fn key, value, acc -> Map.put(acc, key, value) end + Propagator.inject(ctx, %{}, setter, []) + end + + defp build_carrier_from_headers(conn) do + Enum.reduce([@sentry_trace_header, "baggage"], %{}, fn header, acc -> + case Plug.Conn.get_req_header(conn, header) do + [value | _] -> Map.put(acc, header, value) + _ -> acc + end + end) + end + + defp attach_from_headers(conn, carrier) do + ctx = maybe_extract(carrier) + + case ctx do + nil -> + conn + + _ -> + span_ctx = Tracer.current_span_ctx(ctx) + + if span_ctx == :undefined do + conn + else + token = Ctx.attach(ctx) + register_detach(conn, token) + end + end + end + + defp maybe_extract(carrier) do + ctx = Ctx.get_current() + getter = fn key, _ -> Map.get(carrier, key, :undefined) end + Propagator.extract(ctx, carrier, fn _ -> Map.keys(carrier) end, getter, []) + rescue + _ -> nil + end + + defp register_detach(conn, token) do + conn + |> Plug.Conn.put_private(@ctx_token_key, token) + |> Plug.Conn.register_before_send(fn conn -> + _ = + case conn.private[@ctx_token_key] do + nil -> :ok + token -> Ctx.detach(token) + end + + conn + end) + end + + @doc false + def delete_session_key(conn) do + Plug.Conn.delete_session(conn, @session_key) + end + end +end diff --git a/lib/sentry/plug/live_view_context_cleanup.ex b/lib/sentry/plug/live_view_context_cleanup.ex new file mode 100644 index 00000000..33670840 --- /dev/null +++ b/lib/sentry/plug/live_view_context_cleanup.ex @@ -0,0 +1,17 @@ +if Code.ensure_loaded?(Plug) do + defmodule Sentry.Plug.LiveViewContextCleanup do + @moduledoc false + + @behaviour Plug + + alias Sentry.Plug.LiveViewContext + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + Plug.Conn.register_before_send(conn, &LiveViewContext.delete_session_key/1) + end + end +end diff --git a/mix.exs b/mix.exs index 22fe76c6..4979939c 100644 --- a/mix.exs +++ b/mix.exs @@ -50,7 +50,13 @@ defmodule Sentry.Mixfile do "Upgrade Guides": [~r{^pages/upgrade}] ], groups_for_modules: [ - "Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext, Sentry.LiveViewHook], + "Plug and Phoenix": [ + Sentry.PlugCapture, + Sentry.PlugContext, + Sentry.Plug.LiveViewContext, + Sentry.LiveViewHook, + Sentry.Phoenix.LiveViewTracing + ], Loggers: [Sentry.LoggerBackend, Sentry.LoggerHandler], "Data Structures": [Sentry.Attachment, Sentry.CheckIn, Sentry.ClientReport], HTTP: [Sentry.HTTPClient, Sentry.HackneyClient], diff --git a/pages/setup-with-plug-and-phoenix.md b/pages/setup-with-plug-and-phoenix.md deleted file mode 100644 index d5374848..00000000 --- a/pages/setup-with-plug-and-phoenix.md +++ /dev/null @@ -1,105 +0,0 @@ -# Setup with Plug and Phoenix - -You can enrich errors in Plug (and Phoenix) applications with `Sentry.PlugContext`. `Sentry.PlugContext` adds contextual metadata from the current request which is then included in errors. - -## For Phoenix Applications - -If you are using Phoenix: - - 1. Add `Sentry.PlugContext` below `Plug.Parsers` - -```diff - defmodule MyAppWeb.Endpoint - use Phoenix.Endpoint, otp_app: :my_app - - # ... - - plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], - pass: ["*/*"], - json_decoder: Phoenix.json_library() - -+ plug Sentry.PlugContext -``` - - 1. If you're using Cowboy, also add `Sentry.PlugCapture` above the `use Phoenix.Endpoint` line in your endpoint file - -```diff - defmodule MyAppWeb.Endpoint -+ use Sentry.PlugCapture - use Phoenix.Endpoint, otp_app: :my_app - - # ... -``` - -If you're also using [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view), consider also setting up your LiveViews to use the `Sentry.LiveViewHook` hook: - -```elixir -defmodule MyAppWeb do - def live_view do - quote do - use Phoenix.LiveView - - on_mount Sentry.LiveViewHook - end - end -end -``` - -### Capturing User Feedback - -If you would like to capture user feedback as described [here](https://docs.sentry.io/platforms/elixir/enriching-events/user-feedback/), the `Sentry.get_last_event_id_and_source/0` function can be used to see if Sentry has sent an event within the current Plug process (and get the source of that event). `:plug` will be the source for events coming from `Sentry.PlugCapture`. The options described in the Sentry documentation linked above can be encoded into the response as well. - -An example Phoenix application setup that displays the user feedback form on 500 responses on requests accepting HTML could look like this: - -```elixir -defmodule MyAppWeb.ErrorView do - # ... - - def render("500.html", _assigns) do - case Sentry.get_last_event_id_and_source() do - {event_id, :plug} when is_binary(event_id) -> - opts = JSON.encode!(%{eventId: event_id}) - - ~E""" - - - """ - - _ -> - "Error" - end - end -end -``` - -## For Plug Applications - -If you are in a non-Phoenix Plug application: - - 1. Add `Sentry.PlugContext` below `Plug.Parsers` (if it is in your stack) - -```diff - defmodule MyApp.Router do - use Plug.Router - - # ... - - plug Plug.Parsers, - parsers: [:urlencoded, :multipart] - -+ plug Sentry.PlugContext -``` - - 1. If you're using Cowboy, add `Sentry.PlugCapture` at the top of your Plug application - -```diff - defmodule MyApp.Router do - use Plug.Router -+ use Sentry.PlugCapture - - # ... -``` \ No newline at end of file diff --git a/test/sentry/opentelemetry/propagator_test.exs b/test/sentry/opentelemetry/propagator_test.exs new file mode 100644 index 00000000..dbf97d8c --- /dev/null +++ b/test/sentry/opentelemetry/propagator_test.exs @@ -0,0 +1,311 @@ +defmodule Sentry.OpenTelemetry.PropagatorTest do + use ExUnit.Case, async: true + + alias Sentry.OpenTelemetry.Propagator + + @moduletag skip: not Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() + + if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + require OpenTelemetry.Tracer, as: Tracer + require Record + + @fields Record.extract(:span_ctx, from_lib: "opentelemetry_api/include/opentelemetry.hrl") + Record.defrecordp(:span_ctx, @fields) + + @span_fields Record.extract(:span, from_lib: "opentelemetry/include/otel_span.hrl") + Record.defrecordp(:span_record, :span, @span_fields) + + describe "fields/1" do + test "returns the header fields used by the propagator" do + assert Propagator.fields([]) == ["sentry-trace", "baggage"] + end + end + + describe "inject/4" do + test "injects sentry-trace header from current span context" do + trace_id = 0x1234567890ABCDEF1234567890ABCDEF + span_id = 0x1234567890ABCDEF + trace_flags = 1 + + span_context = + span_ctx( + trace_id: trace_id, + span_id: span_id, + trace_flags: trace_flags, + tracestate: [], + is_valid: true, + is_remote: false + ) + + ctx = Tracer.set_current_span(:otel_ctx.new(), span_context) + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + assert Map.has_key?(carrier, "sentry-trace") + sentry_trace = Map.get(carrier, "sentry-trace") + + # Verify format: {trace_id}-{span_id}-{sampled} + assert sentry_trace =~ ~r/^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/ + assert String.ends_with?(sentry_trace, "-1") + end + + test "does not inject when no span context is present" do + ctx = :otel_ctx.new() + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + assert carrier == %{} + end + end + + describe "extract/5" do + test "extracts sentry-trace header and sets remote span context" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef-1" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + "baggage" -> :undefined + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify that a remote span context was set + span_ctx = Tracer.current_span_ctx(ctx) + assert span_ctx != :undefined + + # Verify trace and span IDs were converted correctly + expected_trace_id = 0x1234567890ABCDEF1234567890ABCDEF + expected_span_id = 0x1234567890ABCDEF + + assert span_ctx(span_ctx, :trace_id) == expected_trace_id + assert span_ctx(span_ctx, :span_id) == expected_span_id + assert span_ctx(span_ctx, :trace_flags) == 1 + assert span_ctx(span_ctx, :is_remote) == true + end + + test "extracts sentry-trace without sampled flag" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify remote span context was set + span_ctx = Tracer.current_span_ctx(ctx) + assert span_ctx != :undefined + + # Even when not sampled in the header, trace_flags should be 1 + # because we always simulate sampled traces on the OTel side + assert span_ctx(span_ctx, :trace_flags) == 1 + assert span_ctx(span_ctx, :is_remote) == true + end + + test "handles missing sentry-trace header" do + getter = fn _key, _carrier -> :undefined end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Context should be unchanged + assert Tracer.current_span_ctx(ctx) == :undefined + end + + test "handles invalid sentry-trace header format" do + invalid_headers = [ + "invalid", + "1234-5678", + "toolong1234567890abcdef1234567890abcdef-1234567890abcdef-1" + ] + + for invalid_header <- invalid_headers do + getter = fn key, _carrier -> + case key do + "sentry-trace" -> invalid_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Invalid headers should be ignored - no remote span should be set + assert Tracer.current_span_ctx(ctx) == :undefined + end + end + + test "extracts and stores baggage header" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef-1" + + baggage_header = + "sentry-trace_id=771a43a4192642f0b136d5159a501700," <> + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," <> + "sentry-sample_rate=0.01337,sentry-user_id=Am%C3%A9lie" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + "baggage" -> baggage_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify baggage was stored in context + stored_baggage = :otel_ctx.get_value(ctx, :"sentry-baggage", :not_found) + assert stored_baggage == baggage_header + end + + test "handles missing baggage header" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-1234567890abcdef-1" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + _ -> :undefined + end + end + + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Verify baggage is not set when not provided + stored_baggage = :otel_ctx.get_value(ctx, :"sentry-baggage", :not_found) + assert stored_baggage == :not_found + end + end + + describe "baggage propagation" do + test "injects baggage from context" do + trace_id = 0x1234567890ABCDEF1234567890ABCDEF + span_id = 0x1234567890ABCDEF + trace_flags = 1 + baggage_value = "sentry-trace_id=771a43a4192642f0b136d5159a501700,sentry-release=1.0.0" + + span_context = + span_ctx( + trace_id: trace_id, + span_id: span_id, + trace_flags: trace_flags, + tracestate: [], + is_valid: true, + is_remote: false + ) + + ctx = + :otel_ctx.new() + |> Tracer.set_current_span(span_context) + |> :otel_ctx.set_value(:"sentry-baggage", baggage_value) + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + # Verify both sentry-trace and baggage are injected + assert Map.has_key?(carrier, "sentry-trace") + assert Map.get(carrier, "baggage") == baggage_value + end + + test "does not inject baggage when not in context" do + trace_id = 0x1234567890ABCDEF1234567890ABCDEF + span_id = 0x1234567890ABCDEF + trace_flags = 1 + + span_context = + span_ctx( + trace_id: trace_id, + span_id: span_id, + trace_flags: trace_flags, + tracestate: [], + is_valid: true, + is_remote: false + ) + + ctx = Tracer.set_current_span(:otel_ctx.new(), span_context) + + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + # Verify only sentry-trace is injected + assert Map.has_key?(carrier, "sentry-trace") + assert not Map.has_key?(carrier, "baggage") + end + end + + describe "integration with OpenTelemetry" do + test "round-trip inject and extract preserves trace context" do + # Start a span to create a trace context + Tracer.with_span "test_span" do + ctx = :otel_ctx.get_current() + span_ctx = Tracer.current_span_ctx(ctx) + + original_trace_id = span_ctx(span_ctx, :trace_id) + original_span_id = span_ctx(span_ctx, :span_id) + + # Inject into carrier + setter = fn key, value, carrier -> + Map.put(carrier, key, value) + end + + carrier = Propagator.inject(ctx, %{}, setter, []) + + # Extract from carrier + getter = fn key, carrier -> + Map.get(carrier, key, :undefined) + end + + new_ctx = Propagator.extract(:otel_ctx.new(), carrier, nil, getter, []) + new_span_ctx = Tracer.current_span_ctx(new_ctx) + + # Trace ID should be preserved + assert span_ctx(new_span_ctx, :trace_id) == original_trace_id + + # The span ID in the new context becomes the parent span ID + assert span_ctx(new_span_ctx, :span_id) == original_span_id + end + end + + test "extracted remote span is used as parent for new spans" do + sentry_trace_header = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" + + getter = fn key, _carrier -> + case key do + "sentry-trace" -> sentry_trace_header + _ -> :undefined + end + end + + # Extract the remote span context + ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, []) + + # Attach the context (this is what :otel_propagator_text_map.extract/1 does) + _token = :otel_ctx.attach(ctx) + + # Start a new span - it should use the extracted context as parent + new_span_ctx = Tracer.start_span("child_span", %{kind: :server}) + + # The new span should have the same trace_id as the extracted context + expected_trace_id = 0x1234567890ABCDEF1234567890ABCDEF + assert span_ctx(new_span_ctx, :trace_id) == expected_trace_id + end + end + end +end diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index eaa76f39..abd4ca8d 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -1,6 +1,10 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do use Sentry.Case, async: false + require OpenTelemetry.Tracer, as: Tracer + require OpenTelemetry.SemConv.Incubating.HTTPAttributes, as: HTTPAttributes + require OpenTelemetry.SemConv.Incubating.URLAttributes, as: URLAttributes + import Sentry.TestHelpers alias Sentry.OpenTelemetry.SpanStorage @@ -188,8 +192,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Sentry.Test.start_collecting_sentry_reports() - require OpenTelemetry.Tracer, as: Tracer - Tracer.with_span "root_span" do Tracer.with_span "level_1_child" do Tracer.with_span "level_2_child" do @@ -247,8 +249,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do tasks = Enum.map(1..20, fn i -> Task.async(fn -> - require OpenTelemetry.Tracer, as: Tracer - Tracer.with_span "concurrent_root_#{i}" do Tracer.with_span "concurrent_child_#{i}" do Process.sleep(10) @@ -286,8 +286,6 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Sentry.Test.start_collecting_sentry_reports() - require OpenTelemetry.Tracer, as: Tracer - Tracer.with_span "root_span" do Tracer.with_span "child_instrumented_function_one" do Process.sleep(10) @@ -310,5 +308,145 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Application.put_env(:opentelemetry, :sampler, original_sampler) end + + @tag span_storage: true + test "treats HTTP server request spans as transaction roots for distributed tracing" do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + # Simulate an incoming HTTP request with an external parent span ID (from browser/client) + # This represents a distributed trace where the client started the trace + external_trace_id = 0x1234567890ABCDEF1234567890ABCDEF + external_parent_span_id = 0xABCDEF1234567890 + + # Create a remote parent span context using :otel_tracer.from_remote_span + remote_parent = :otel_tracer.from_remote_span(external_trace_id, external_parent_span_id, 1) + + ctx = Tracer.set_current_span(:otel_ctx.new(), remote_parent) + + # Start an HTTP server span with the remote parent context + Tracer.with_span ctx, "POST /api/users", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :POST, + URLAttributes.url_path() => "/api/users", + "http.route" => "/api/users", + "server.address" => "localhost", + "server.port" => 4000 + } + } do + # Simulate child spans (database queries, etc.) + Tracer.with_span "db.query:users", %{ + kind: :client, + attributes: %{ + "db.system" => :postgresql, + "db.statement" => "INSERT INTO users (name) VALUES ($1)" + } + } do + Process.sleep(10) + end + + Tracer.with_span "db.query:notifications", %{ + kind: :client, + attributes: %{ + "db.system" => :postgresql, + "db.statement" => "INSERT INTO notifications (user_id) VALUES ($1)" + } + } do + Process.sleep(10) + end + end + + # Should capture the HTTP request span as a transaction root despite having an external parent + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + # Verify transaction properties + assert transaction.transaction == "POST /api/users" + assert transaction.transaction_info == %{source: :custom} + assert length(transaction.spans) == 2 + + # Verify child spans are properly included + span_ops = Enum.map(transaction.spans, & &1.op) |> Enum.sort() + assert span_ops == ["db", "db"] + + # Verify child spans have detailed data (like SQL queries) + [span1, span2] = transaction.spans + assert span1.description =~ "INSERT INTO" + assert span2.description =~ "INSERT INTO" + assert span1.data["db.system"] == :postgresql + assert span2.data["db.system"] == :postgresql + assert span1.data["db.statement"] =~ "INSERT INTO users" + assert span2.data["db.statement"] =~ "INSERT INTO notifications" + + # Verify all spans share the same trace ID (from the external parent) + trace_id = transaction.contexts.trace.trace_id + + Enum.each(transaction.spans, fn span -> + assert span.trace_id == trace_id + end) + + # The transaction should have the external parent's trace ID + assert transaction.contexts.trace.trace_id == + "1234567890abcdef1234567890abcdef" + end + + @tag span_storage: true + test "cleans up HTTP server span and children after sending distributed trace transaction", %{ + table_name: table_name + } do + put_test_config(environment_name: "test", traces_sample_rate: 1.0) + + Sentry.Test.start_collecting_sentry_reports() + + # Simulate an incoming HTTP request with an external parent span ID (from browser/client) + external_trace_id = 0x1234567890ABCDEF1234567890ABCDEF + external_parent_span_id = 0xABCDEF1234567890 + + remote_parent = :otel_tracer.from_remote_span(external_trace_id, external_parent_span_id, 1) + ctx = Tracer.set_current_span(:otel_ctx.new(), remote_parent) + + # Start an HTTP server span with the remote parent context + Tracer.with_span ctx, "POST /api/users", %{ + kind: :server, + attributes: %{ + HTTPAttributes.http_request_method() => :POST, + URLAttributes.url_path() => "/api/users" + } + } do + # Simulate child spans (database queries, etc.) + Tracer.with_span "db.query:users", %{ + kind: :client, + attributes: %{ + "db.system" => :postgresql, + "db.statement" => "INSERT INTO users (name) VALUES ($1)" + } + } do + Process.sleep(10) + end + end + + # Should capture the HTTP request span as a transaction + assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + + # Verify the HTTP server span was removed from storage + # (even though it was stored as a child span due to having a remote parent) + http_server_span_id = transaction.contexts.trace.span_id + remote_parent_span_id_str = "abcdef1234567890" + + # The HTTP server span should not exist in storage anymore + assert SpanStorage.get_root_span(http_server_span_id, table_name: table_name) == nil + + # Check that it was also removed from child spans storage + # We can't directly check if a specific child was removed, but we can verify + # that get_child_spans for the remote parent returns empty (or doesn't include our span) + remaining_children = + SpanStorage.get_child_spans(remote_parent_span_id_str, table_name: table_name) + + refute Enum.any?(remaining_children, fn span -> span.span_id == http_server_span_id end) + + # Verify child spans of the HTTP server span were also removed + assert [] == SpanStorage.get_child_spans(http_server_span_id, table_name: table_name) + end end end diff --git a/test_integrations/phoenix_app/config/config.exs b/test_integrations/phoenix_app/config/config.exs index 68901111..2eae6806 100644 --- a/test_integrations/phoenix_app/config/config.exs +++ b/test_integrations/phoenix_app/config/config.exs @@ -60,10 +60,14 @@ config :logger, :console, config :phoenix, :json_library, if(Code.ensure_loaded?(JSON), do: JSON, else: Jason) -config :opentelemetry, span_processor: {Sentry.OpenTelemetry.SpanProcessor, []} - config :opentelemetry, - sampler: {Sentry.OpenTelemetry.Sampler, [drop: ["Elixir.Oban.Stager process"]]} + span_processor: {Sentry.OpenTelemetry.SpanProcessor, []}, + sampler: {Sentry.OpenTelemetry.Sampler, [drop: ["Elixir.Oban.Stager process"]]}, + text_map_propagators: [ + Sentry.OpenTelemetry.Propagator, + :trace_context, + :baggage + ] # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/test_integrations/phoenix_app/config/dev.exs b/test_integrations/phoenix_app/config/dev.exs index 8dc26871..1489cd8f 100644 --- a/test_integrations/phoenix_app/config/dev.exs +++ b/test_integrations/phoenix_app/config/dev.exs @@ -84,12 +84,19 @@ dsn = do: System.get_env("SENTRY_DSN_LOCAL"), else: System.get_env("SENTRY_DSN") +# For e2e tracing tests, use the TestClient to log events to a file +client = + if System.get_env("SENTRY_E2E_TEST_MODE") == "true", + do: PhoenixApp.TestClient, + else: Sentry.HackneyClient + config :sentry, dsn: dsn, environment_name: :dev, enable_source_code_context: true, send_result: :sync, - traces_sample_rate: 1.0 + traces_sample_rate: 1.0, + client: client config :phoenix_app, Oban, repo: PhoenixApp.Repo, diff --git a/test_integrations/phoenix_app/lib/phoenix_app/test_client.ex b/test_integrations/phoenix_app/lib/phoenix_app/test_client.ex new file mode 100644 index 00000000..43a33ac8 --- /dev/null +++ b/test_integrations/phoenix_app/lib/phoenix_app/test_client.ex @@ -0,0 +1,86 @@ +defmodule PhoenixApp.TestClient do + @moduledoc """ + A test Sentry client that logs envelopes to a file for e2e test validation. + + This client mimics the behavior of Sentry::DebugTransport in sentry-ruby, + logging all envelopes to a file that can be read by Playwright tests. + """ + + require Logger + + @behaviour Sentry.HTTPClient + + @impl true + def post(_url, _headers, body) do + log_envelope(body) + + # Return success response + {:ok, 200, [], ~s({"id":"test-event-id"})} + end + + defp log_envelope(body) when is_binary(body) do + log_file = Path.join([File.cwd!(), "tmp", "sentry_debug_events.log"]) + + # Ensure the tmp directory exists + log_dir = Path.dirname(log_file) + File.mkdir_p!(log_dir) + + # Parse the envelope binary to extract events and headers + case parse_envelope(body) do + {:ok, envelope_data} -> + # Write the envelope data as JSON + json = Jason.encode!(envelope_data) + File.write!(log_file, json <> "\n", [:append]) + + {:error, reason} -> + Logger.warning("Failed to parse envelope for logging: #{inspect(reason)}") + end + rescue + error -> + Logger.warning("Failed to log envelope: #{inspect(error)}") + end + + defp parse_envelope(body) when is_binary(body) do + # Envelope format: header\nitem_header\nitem_payload[\nitem_header\nitem_payload...] + # See: https://develop.sentry.dev/sdk/envelopes/ + + lines = String.split(body, "\n") + + with {:ok, header_line, rest} <- get_first_line(lines), + {:ok, envelope_headers} <- Jason.decode(header_line), + {:ok, items} <- parse_items(rest) do + + envelope = %{ + headers: envelope_headers, + items: items + } + + {:ok, envelope} + else + error -> {:error, error} + end + end + + defp get_first_line([first | rest]), do: {:ok, first, rest} + defp get_first_line([]), do: {:error, :empty_envelope} + + defp parse_items(lines), do: parse_items(lines, []) + + defp parse_items([], acc), do: {:ok, Enum.reverse(acc)} + + defp parse_items([item_header_line, payload_line | rest], acc) do + with {:ok, _item_header} <- Jason.decode(item_header_line), + {:ok, payload} <- Jason.decode(payload_line) do + parse_items(rest, [payload | acc]) + else + _error -> + # Skip malformed items + parse_items(rest, acc) + end + end + + defp parse_items([_single_line], acc) do + # Handle trailing empty line + {:ok, Enum.reverse(acc)} + end +end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex index 8b31383c..0f9be76c 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex @@ -48,4 +48,37 @@ defmodule PhoenixAppWeb.PageController do render(conn, :home, layout: false) end + + # E2E tracing test endpoints + def api_error(_conn, _params) do + raise ArithmeticError, "bad argument in arithmetic expression" + end + + def health(conn, _params) do + json(conn, %{status: "ok"}) + end + + def api_data(conn, _params) do + # Fetch actual data from the database + Tracer.with_span "fetch_data" do + users = Repo.all(User) + + Tracer.with_span "process_data" do + # Do some processing + user_count = length(users) + + # Make another query to demonstrate nested DB operations + first_user = Repo.get(User, 1) + + json(conn, %{ + message: "Data fetched successfully", + data: %{ + user_count: user_count, + first_user: if(first_user, do: first_user.name, else: nil), + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + } + }) + end + end + end end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex index cbc6c40a..bbeab22f 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/endpoint.ex @@ -44,6 +44,25 @@ defmodule PhoenixAppWeb.Endpoint do plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + # Simple CORS handler for e2e tests + plug :cors + + defp cors(conn, _opts) do + conn + |> put_resp_header("access-control-allow-origin", "*") + |> put_resp_header("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS") + |> put_resp_header("access-control-allow-headers", "content-type, sentry-trace, baggage") + |> then(fn conn -> + if conn.method == "OPTIONS" do + conn + |> send_resp(200, "") + |> halt() + else + conn + end + end) + end + plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], @@ -51,6 +70,8 @@ defmodule PhoenixAppWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head + plug Sentry.Plug.LiveViewContextCleanup plug Plug.Session, @session_options + plug Sentry.Plug.LiveViewContext plug PhoenixAppWeb.Router end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex index 597506b1..18440b85 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex @@ -12,6 +12,14 @@ defmodule PhoenixAppWeb.Router do pipeline :api do plug :accepts, ["json"] + plug :put_cors_headers + end + + defp put_cors_headers(conn, _opts) do + conn + |> put_resp_header("access-control-allow-origin", "*") + |> put_resp_header("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS") + |> put_resp_header("access-control-allow-headers", "content-type, authorization, sentry-trace, baggage") end scope "/", PhoenixAppWeb do @@ -22,14 +30,25 @@ defmodule PhoenixAppWeb.Router do get "/transaction", PageController, :transaction get "/nested-spans", PageController, :nested_spans - live "/test-worker", TestWorkerLive + live_session :default, on_mount: {Sentry.Phoenix.LiveViewTracing, :attach} do + live "/test-worker", TestWorkerLive - live "/users", UserLive.Index, :index - live "/users/new", UserLive.Index, :new - live "/users/:id/edit", UserLive.Index, :edit + 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 + end + + # API endpoints for e2e tracing tests + scope "/", PhoenixAppWeb do + pipe_through :api - live "/users/:id", UserLive.Show, :show - live "/users/:id/show/edit", UserLive.Show, :edit + get "/error", PageController, :api_error + get "/health", PageController, :health + get "/api/data", PageController, :api_data end # Other scopes may use custom stacks. diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs index 49d4faa3..62918e0a 100644 --- a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs @@ -129,4 +129,34 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do refute mount_transaction.contexts.trace.trace_id == handle_params_transaction.contexts.trace.trace_id end + + test "GET /users with distributed tracing headers includes child spans with details", %{conn: conn} do + # Ensure the `sentry-trace` header controls the trace ID so both the disconnected + # mount and the connected `handle_params` transaction emit spans on the same trace. + + trace_id = "1234567890abcdef1234567890abcdef" + span_id = "1234567890abcdef" + sentry_trace = "#{trace_id}-#{span_id}-1" + + conn = put_req_header(conn, "sentry-trace", sentry_trace) + get(conn, ~p"/users") + + transactions = Sentry.Test.pop_sentry_transactions() + + assert length(transactions) == 2 + + assert [mount_transaction, handle_params_transaction] = transactions + + assert mount_transaction.contexts.trace.trace_id == trace_id + assert mount_transaction.contexts.trace.parent_span_id != nil + + assert [span_ecto] = mount_transaction.spans + assert span_ecto.op == "db" + + assert span_ecto.description == "SELECT u0.\"id\", u0.\"name\", u0.\"age\", u0.\"inserted_at\", u0.\"updated_at\" FROM \"users\" AS u0" + + assert span_ecto.data["db.system"] != nil + + assert handle_params_transaction.contexts.trace.trace_id == trace_id + end end diff --git a/test_integrations/tracing/.gitignore b/test_integrations/tracing/.gitignore new file mode 100644 index 00000000..e9155d01 --- /dev/null +++ b/test_integrations/tracing/.gitignore @@ -0,0 +1,27 @@ +*.beam +*.ez +_build/ +deps/ +doc/ +.fetch +erl_crash.dump +*.plt +*.plt.hash +esbuild +.elixir_ls/ +.lexical/ + +# Phoenix mini +phoenix_mini/tmp/ +phoenix_mini/_build/ +phoenix_mini/deps/ + +# Svelte mini +svelte_mini/node_modules/ +svelte_mini/dist/ +svelte_mini/.vite/ +svelte_mini/package-lock.json + +# Test artifacts +tmp/ +.DS_Store diff --git a/test_integrations/tracing/Procfile b/test_integrations/tracing/Procfile new file mode 100644 index 00000000..10847bff --- /dev/null +++ b/test_integrations/tracing/Procfile @@ -0,0 +1,2 @@ +phoenix: cd ../phoenix_app && mix phx.server +svelte: cd svelte_mini && npm run dev diff --git a/test_integrations/tracing/mix.exs b/test_integrations/tracing/mix.exs new file mode 100644 index 00000000..330e6cea --- /dev/null +++ b/test_integrations/tracing/mix.exs @@ -0,0 +1,90 @@ +defmodule TracingTest.MixProject do + use Mix.Project + + def project do + [ + app: :tracing_test, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases() + ] + end + + def application do + [ + extra_applications: [:logger, :crypto] + ] + end + + defp deps do + [ + {:jason, "~> 1.2"}, + {:httpoison, "~> 2.0"} + ] + end + + defp aliases do + [ + test: &run_playwright_tests/1, + setup: &setup_environment/1, + "dev.start": &start_dev_servers/1 + ] + end + + defp setup_environment(_args) do + Mix.shell().info("Installing npm dependencies...") + System.cmd("npm", ["install"], into: IO.stream(:stdio, :line)) + + # Install Playwright browsers + Mix.shell().info("Installing Playwright browsers...") + System.cmd("npx", ["playwright", "install"], into: IO.stream(:stdio, :line)) + + # Setup phoenix_app dependencies + phoenix_app_path = Path.join([__DIR__, "..", "phoenix_app"]) + Mix.shell().info("Installing Phoenix app dependencies...") + System.cmd("mix", ["deps.get"], cd: phoenix_app_path, into: IO.stream(:stdio, :line)) + + Mix.shell().info("Setup complete!") + end + + defp start_dev_servers(_args) do + # Check if overmind is available + case System.cmd("which", ["overmind"], stderr_to_stdout: true) do + {_, 0} -> + Mix.shell().info("Starting servers with Overmind...") + System.cmd("overmind", ["start"], into: IO.stream(:stdio, :line)) + + _ -> + Mix.shell().error(""" + Overmind is not installed. Please install it: + + macOS: brew install overmind tmux + Linux: go install github.com/DarthSim/overmind/v2@latest + + Then add to PATH: export PATH=$PATH:$(go env GOPATH)/bin + """) + System.at_exit(fn _ -> exit({:shutdown, 1}) end) + end + end + + defp run_playwright_tests(args) do + # Install npm dependencies if needed + if !File.dir?("node_modules") do + Mix.shell().info("Installing npm dependencies...") + System.cmd("npm", ["install"], into: IO.stream(:stdio, :line)) + end + + # Build test command + test_cmd = ["test" | args] + + # Run Playwright tests + Mix.shell().info("Running Playwright tests...") + {_, exit_code} = System.cmd("npm", test_cmd, into: IO.stream(:stdio, :line)) + + if exit_code != 0 do + System.at_exit(fn _ -> exit({:shutdown, 1}) end) + end + end +end diff --git a/test_integrations/tracing/mix.lock b/test_integrations/tracing/mix.lock new file mode 100644 index 00000000..8ef57e14 --- /dev/null +++ b/test_integrations/tracing/mix.lock @@ -0,0 +1,12 @@ +%{ + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.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.4", [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.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, +} diff --git a/test_integrations/tracing/package-lock.json b/test_integrations/tracing/package-lock.json new file mode 100644 index 00000000..31b006c0 --- /dev/null +++ b/test_integrations/tracing/package-lock.json @@ -0,0 +1,104 @@ +{ + "name": "tracing-e2e-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tracing-e2e-tests", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } +} diff --git a/test_integrations/tracing/package.json b/test_integrations/tracing/package.json new file mode 100644 index 00000000..65e35289 --- /dev/null +++ b/test_integrations/tracing/package.json @@ -0,0 +1,15 @@ +{ + "name": "tracing-e2e-tests", + "version": "1.0.0", + "type": "module", + "scripts": { + "test": "playwright test", + "test:debug": "playwright test --debug", + "test:ui": "playwright test --ui" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/test_integrations/tracing/playwright.config.ts b/test_integrations/tracing/playwright.config.ts new file mode 100644 index 00000000..6df8d8c5 --- /dev/null +++ b/test_integrations/tracing/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PHOENIX_URL = + process.env.SENTRY_E2E_PHOENIX_APP_URL || "http://localhost:4000"; +const SVELTE_URL = + process.env.SENTRY_E2E_SVELTE_APP_URL || "http://localhost:4001"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: "list", + + use: { + baseURL: SVELTE_URL, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + headless: true, + }, + }, + ], + + webServer: [ + { + command: + 'cd ../phoenix_app && rm -f tmp/sentry_debug_events.log && SENTRY_E2E_TEST_MODE=true SENTRY_DSN="https://user:secret@sentry.localdomain/42" mix phx.server', + url: `${PHOENIX_URL}/health`, + reuseExistingServer: !process.env.CI, + timeout: 30000, + }, + { + command: + 'cd svelte_mini && SENTRY_DSN="https://user:secret@sentry.localdomain/42" SENTRY_E2E_PHOENIX_APP_URL="http://localhost:4000" SENTRY_E2E_SVELTE_APP_PORT=4001 npm run dev', + url: `${SVELTE_URL}/health`, + reuseExistingServer: !process.env.CI, + timeout: 30000, + }, + ], +}); diff --git a/test_integrations/tracing/svelte_mini/index.html b/test_integrations/tracing/svelte_mini/index.html new file mode 100644 index 00000000..88e70534 --- /dev/null +++ b/test_integrations/tracing/svelte_mini/index.html @@ -0,0 +1,15 @@ + + + + + + + Svelte Mini App + + + +
+ + + + diff --git a/test_integrations/tracing/svelte_mini/package.json b/test_integrations/tracing/svelte_mini/package.json new file mode 100644 index 00000000..ef305e42 --- /dev/null +++ b/test_integrations/tracing/svelte_mini/package.json @@ -0,0 +1,18 @@ +{ + "name": "svelte-mini", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "~5.3.0", + "vite": "^6.0.0" + }, + "dependencies": { + "@sentry/svelte": "~10.5" + } +} diff --git a/test_integrations/tracing/svelte_mini/src/App.svelte b/test_integrations/tracing/svelte_mini/src/App.svelte new file mode 100644 index 00000000..1097e72f --- /dev/null +++ b/test_integrations/tracing/svelte_mini/src/App.svelte @@ -0,0 +1,128 @@ + + +
+

Svelte Mini App

+

+ Test distributed tracing between frontend and backend: +

+ +
+ + + +
+ + {#if result} +
+

Result:

+
{result}
+
+ {/if} +
+ + diff --git a/test_integrations/tracing/svelte_mini/src/main.js b/test_integrations/tracing/svelte_mini/src/main.js new file mode 100644 index 00000000..cb854f31 --- /dev/null +++ b/test_integrations/tracing/svelte_mini/src/main.js @@ -0,0 +1,19 @@ +import * as Sentry from "@sentry/svelte"; +import { mount } from "svelte"; +import App from "./App.svelte" + +Sentry.init({ + dsn: import.meta.env.SENTRY_DSN || "https://user:secret@sentry.localdomain/42", + debug: true, + integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], + tracesSampleRate: 1.0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + tracePropagationTargets: ["localhost:4000"], +}); + +const app = mount(App, { + target: document.getElementById("app"), +}) + +export default app diff --git a/test_integrations/tracing/svelte_mini/vite.config.js b/test_integrations/tracing/svelte_mini/vite.config.js new file mode 100644 index 00000000..36590bbf --- /dev/null +++ b/test_integrations/tracing/svelte_mini/vite.config.js @@ -0,0 +1,34 @@ +import { defineConfig } from "vite" +import { svelte } from "@sveltejs/vite-plugin-svelte" + +export default defineConfig({ + plugins: [ + svelte(), + { + name: "health-check", + configureServer(server) { + server.middlewares.use("/health", (req, res, next) => { + if (req.method === "GET") { + res.setHeader("Content-Type", "application/json") + res.setHeader("Access-Control-Allow-Origin", "*") + res.end(JSON.stringify({ + status: "ok", + timestamp: new Date().toISOString(), + service: "svelte-mini" + })) + } else { + next() + } + }) + } + } + ], + server: { + port: parseInt(process.env.SENTRY_E2E_SVELTE_APP_PORT || "4001"), + host: "0.0.0.0", + }, + define: { + SENTRY_E2E_PHOENIX_APP_URL: JSON.stringify(process.env.SENTRY_E2E_PHOENIX_APP_URL || "http://localhost:4000") + }, + envPrefix: ["SENTRY_"] +}) diff --git a/test_integrations/tracing/tests/helpers.ts b/test_integrations/tracing/tests/helpers.ts new file mode 100644 index 00000000..f2240a13 --- /dev/null +++ b/test_integrations/tracing/tests/helpers.ts @@ -0,0 +1,156 @@ +import { expect } from "@playwright/test"; +import fs from "fs"; +import path from "path"; + +const DEBUG_LOG_PATH = path.join( + process.cwd(), + "..", + "phoenix_app", + "tmp", + "sentry_debug_events.log" +); + +export interface SentryEvent { + type?: string; + transaction?: string; + exception?: Array<{ + type: string; + value: string; + }>; + contexts?: { + trace?: { + trace_id?: string; + span_id?: string; + parent_span_id?: string; + op?: string; + data?: Record; + }; + }; + _meta?: { + dsc?: { + sample_rand?: string; + }; + }; + request?: { + headers?: Record; + }; +} + +export interface SentryEnvelope { + headers: { + trace?: { + trace_id?: string; + sample_rate?: string; + sample_rand?: string; + sampled?: string; + environment?: string; + public_key?: string; + [key: string]: any; + }; + }; + items: SentryEvent[]; +} + +export interface LoggedEvents { + events: SentryEvent[]; + envelopes: SentryEnvelope[]; + event_count: number; +} + +export function getLoggedEvents(): LoggedEvents { + const events: SentryEvent[] = []; + const envelopes: SentryEnvelope[] = []; + + if (!fs.existsSync(DEBUG_LOG_PATH)) { + return { events: [], envelopes: [], event_count: 0 }; + } + + const content = fs.readFileSync(DEBUG_LOG_PATH, "utf-8"); + const lines = content + .trim() + .split("\n") + .filter((line) => line.trim()); + + for (const line of lines) { + try { + const data = JSON.parse(line); + + // Check if it's an envelope format + if (data.headers) { + envelopes.push(data as SentryEnvelope); + if (data.items) { + events.push(...data.items); + } + } else { + // Individual event + events.push(data as SentryEvent); + } + } catch (e) { + // Skip malformed lines + } + } + + return { events, envelopes, event_count: events.length }; +} + +export function clearLoggedEvents(): void { + if (fs.existsSync(DEBUG_LOG_PATH)) { + fs.writeFileSync(DEBUG_LOG_PATH, ""); + } +} + +export function expectValidSampleRand(sampleRand: string | undefined): void { + expect(sampleRand).toBeDefined(); + expect(sampleRand).toMatch(/^\d+\.\d+$/); + + const value = parseFloat(sampleRand!); + expect(value).toBeGreaterThanOrEqual(0.0); + expect(value).toBeLessThan(1.0); +} + +export function expectDscInEnvelopeHeaders() { + const { envelopes } = getLoggedEvents(); + + const envelopesWithDsc = envelopes.filter( + (envelope) => envelope.headers?.trace + ); + + expect(envelopesWithDsc.length).toBeGreaterThan(0); + + const dscMetadata = envelopesWithDsc.map( + (envelope) => envelope.headers.trace + ); + + const envelopesWithSampleRand = dscMetadata.filter((dsc) => dsc?.sample_rand); + expect(envelopesWithSampleRand.length).toBeGreaterThan(0); + + envelopesWithSampleRand.forEach((dsc) => { + expectValidSampleRand(dsc?.sample_rand); + }); + + return dscMetadata; +} + +export function getHttpServerTransactionsWithHeaders(): SentryEvent[] { + const { events } = getLoggedEvents(); + + const transactionEvents = events.filter( + (event) => event.type === "transaction" + ); + expect(transactionEvents.length).toBeGreaterThan(0); + + const httpServerTransactions = transactionEvents.filter( + (event) => event.contexts?.trace?.op === "http.server" + ); + expect(httpServerTransactions.length).toBeGreaterThan(0); + + const transactionsWithHeaders = httpServerTransactions.filter( + (transaction) => { + const headers = transaction.request?.headers; + return headers && (headers["Sentry-Trace"] || headers["sentry-trace"]); + } + ); + expect(transactionsWithHeaders.length).toBeGreaterThan(0); + + return transactionsWithHeaders; +} diff --git a/test_integrations/tracing/tests/tracing.spec.ts b/test_integrations/tracing/tests/tracing.spec.ts new file mode 100644 index 00000000..f8ae402f --- /dev/null +++ b/test_integrations/tracing/tests/tracing.spec.ts @@ -0,0 +1,261 @@ +import { test, expect } from "@playwright/test"; +import { + clearLoggedEvents, + getLoggedEvents, + expectValidSampleRand, + expectDscInEnvelopeHeaders, + getHttpServerTransactionsWithHeaders, +} from "./helpers"; + +test.describe("Tracing", () => { + test.beforeEach(() => { + clearLoggedEvents(); + }); + + test("validates basic tracing functionality", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator("h1")).toContainText("Svelte Mini App"); + await expect(page.locator("button#trigger-error-btn")).toBeVisible(); + + await page.click("button#trigger-error-btn"); + + await expect(page.locator(".result")).toContainText("Error:"); + + // Wait for events to be logged + await page.waitForTimeout(2000); + + const logged = getLoggedEvents(); + expect(logged.event_count).toBeGreaterThan(0); + + // Check for error events + const errorEvents = logged.events.filter((event) => event.exception); + expect(errorEvents.length).toBeGreaterThan(0); + + const errorEvent = errorEvents[errorEvents.length - 1]; + // In Sentry, exception is directly an array, not exception.values + const exceptionValues = errorEvent.exception; + expect(exceptionValues).toBeDefined(); + expect(exceptionValues!.length).toBeGreaterThan(0); + expect(exceptionValues![0].type).toBe("ArithmeticError"); + + // Check trace context + // NOTE: Error events captured via Logger don't have trace context + // Only transactions have trace context from OpenTelemetry + + // Check for transaction events + const transactionEvents = logged.events.filter( + (event) => event.type === "transaction" + ); + expect(transactionEvents.length).toBeGreaterThan(0); + + // Validate that transactions have proper OpenTelemetry trace context + const errorTransactions = transactionEvents.filter( + (event) => event.transaction?.includes("error") || event.transaction?.includes("GET") + ); + + errorTransactions.forEach((transaction) => { + const traceContext = transaction.contexts?.trace; + expect(traceContext).toBeDefined(); + expect(traceContext?.trace_id).toBeDefined(); + expect(traceContext?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(traceContext?.span_id).toBeDefined(); + expect(traceContext?.span_id).toMatch(/^[a-f0-9]{16}$/); + expect(traceContext?.op).toBe("http.server"); + + // Validate OpenTelemetry semantic data + const traceData = traceContext?.data as Record | undefined; + expect(traceData).toBeDefined(); + expect(traceData?.["http.request.method"]).toBe("GET"); + expect(traceData?.["http.route"]).toBe("/error"); + expect(traceData?.["phoenix.action"]).toBe("api_error"); + }); + }); + + test.describe("OpenTelemetry trace propagation", () => { + test("validates trace IDs are properly generated for backend requests", async ({ + page, + }) => { + await page.goto("/"); + + await expect(page.locator("h1")).toContainText("Svelte Mini App"); + await expect(page.locator("button#trigger-error-btn")).toBeVisible(); + + await page.click("button#trigger-error-btn"); + + await expect(page.locator(".result")).toContainText("Error:"); + + // Wait for events to be logged + await page.waitForTimeout(2000); + + const logged = getLoggedEvents(); + + // Check for transaction events + const transactionEvents = logged.events.filter( + (event) => event.type === "transaction" + ); + expect(transactionEvents.length).toBeGreaterThan(0); + + // Find the error transaction + const errorTransaction = transactionEvents.find((event) => + event.transaction?.includes("error") + ); + expect(errorTransaction).toBeDefined(); + + // Validate trace context + const traceContext = errorTransaction!.contexts?.trace; + expect(traceContext).toBeDefined(); + expect(traceContext?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(traceContext?.span_id).toMatch(/^[a-f0-9]{16}$/); + expect(traceContext?.op).toBe("http.server"); + }); + + test("validates distributed tracing across multiple requests", async ({ + page, + }) => { + await page.goto("/"); + + await expect(page.locator("h1")).toContainText("Svelte Mini App"); + + // Trigger error 3 times - all should be part of the same distributed trace + for (let i = 0; i < 3; i++) { + await page.click("button#trigger-error-btn"); + await page.waitForTimeout(100); + } + + await expect(page.locator(".result")).toContainText("Error:"); + + // Wait for events to be logged + await page.waitForTimeout(2000); + + const logged = getLoggedEvents(); + + // Get all transaction events + const transactionEvents = logged.events.filter( + (event) => event.type === "transaction" + ); + + // Filter for error transactions + const errorTransactions = transactionEvents.filter((event) => + event.transaction?.includes("error") + ); + + // We should have at least 3 error transactions + expect(errorTransactions.length).toBeGreaterThanOrEqual(3); + + // Extract trace IDs + const traceIds = errorTransactions + .map((t) => t.contexts?.trace?.trace_id) + .filter(Boolean); + + // All should have valid trace IDs + expect(traceIds.length).toBeGreaterThanOrEqual(3); + traceIds.forEach((traceId) => { + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + }); + + // With distributed tracing, all requests from the same page load + // should share the SAME trace ID (from the frontend) + const uniqueTraceIds = [...new Set(traceIds)]; + expect(uniqueTraceIds.length).toBe(1); + + // All transactions should have parent_span_id set (proving they're + // continuing a trace from the frontend, not starting new ones) + errorTransactions.forEach((transaction) => { + const parentSpanId = transaction.contexts?.trace?.parent_span_id; + expect(parentSpanId).toBeDefined(); + expect(parentSpanId).toMatch(/^[a-f0-9]{16}$/); + }); + }); + + test("validates child span data is preserved in distributed tracing", async ({ + page, + }) => { + await page.goto("/"); + + await expect(page.locator("h1")).toContainText("Svelte Mini App"); + + // Make a request that includes database operations and nested spans + await page.evaluate(async () => { + const response = await fetch("http://localhost:4000/api/data", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + return response.json(); + }); + + // Wait for events to be logged + await page.waitForTimeout(2000); + + const logged = getLoggedEvents(); + + // Get transaction events for the /api/data endpoint + const transactionEvents = logged.events.filter( + (event) => event.type === "transaction" + ); + expect(transactionEvents.length).toBeGreaterThan(0); + + // Look for transactions related to the /api/data endpoint + const dataTransactions = transactionEvents.filter((event) => + event.transaction?.includes("/api/data") || + event.transaction?.includes("fetch_data") + ); + + expect(dataTransactions.length).toBeGreaterThan(0); + + // Test the distributed tracing transaction + const dataTransaction = dataTransactions[0]; + + // Verify it has a parent span ID (from distributed tracing) + expect(dataTransaction.contexts?.trace?.parent_span_id).toBeDefined(); + expect(dataTransaction.contexts?.trace?.parent_span_id).toMatch( + /^[a-f0-9]{16}$/ + ); + + // THIS IS THE KEY TEST: Verify child spans exist and have detailed data + const spans = (dataTransaction as any).spans; + expect(spans).toBeDefined(); + expect(spans.length).toBeGreaterThan(0); + + // Verify each span has proper structure and data + spans.forEach((span: any) => { + expect(span.span_id).toBeDefined(); + expect(span.span_id).toMatch(/^[a-f0-9]{16}$/); + expect(span.trace_id).toBeDefined(); + expect(span.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(span.op).toBeDefined(); + expect(span.description).toBeDefined(); + + // Verify span has data attributes + expect(span.data).toBeDefined(); + expect(typeof span.data).toBe("object"); + + // At minimum, spans should have otel.kind + expect(span.data["otel.kind"]).toBeDefined(); + }); + + // Check for database spans specifically - they should have detailed DB info + const dbSpans = spans.filter((span: any) => span.op === "db"); + expect(dbSpans.length).toBeGreaterThan(0); + + // Verify DB spans have detailed query information + dbSpans.forEach((dbSpan: any) => { + // Should have db.system + expect(dbSpan.data["db.system"]).toBeDefined(); + + // Should have SQL query in description + expect(dbSpan.description).toBeDefined(); + expect(dbSpan.description.length).toBeGreaterThan(0); + + // For SQLite, should see SELECT queries + expect(dbSpan.description).toMatch(/SELECT/i); + + // Should have db.statement with the actual SQL + expect(dbSpan.data["db.statement"]).toBeDefined(); + expect(dbSpan.data["db.statement"]).toMatch(/SELECT/i); + }); + }); + }); +});